diff --git a/.github/ISSUE_TEMPLATE/pattern_submission.yml b/.github/ISSUE_TEMPLATE/pattern_submission.yml index 72214b89..6bc203a0 100644 --- a/.github/ISSUE_TEMPLATE/pattern_submission.yml +++ b/.github/ISSUE_TEMPLATE/pattern_submission.yml @@ -64,8 +64,8 @@ body: label: True positives (examples that should match) description: Example strings that should trigger this pattern placeholder: | - - AKIAIOSFODNN7EXAMPLE - - AKIA1234567890ABCDEF + - + - validations: required: false @@ -75,7 +75,7 @@ body: label: Known false positives (examples that should NOT match) description: Known strings that trigger this pattern but are not secrets placeholder: | - - Test fixtures with AKIA prefix + - Test fixtures containing credential-shaped placeholders - Example keys in documentation validations: required: false diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 7088e081..7db32f03 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -14,7 +14,7 @@ jobs: claude-review: name: AI Code Review runs-on: ubuntu-latest - if: env.ENABLE_AI_REVIEW == 'true' + if: vars.ENABLE_AI_REVIEW == 'true' permissions: pull-requests: write contents: read @@ -45,7 +45,7 @@ jobs: disabled: name: AI Review runs-on: ubuntu-latest - if: env.ENABLE_AI_REVIEW != 'true' + if: vars.ENABLE_AI_REVIEW != 'true' steps: - name: AI Review is disabled run: | diff --git a/.github/workflows/atheon.yml b/.github/workflows/atheon.yml index fbbff598..2bddb644 100644 --- a/.github/workflows/atheon.yml +++ b/.github/workflows/atheon.yml @@ -23,19 +23,40 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Install Atheon-Enhanced run: | - wget -q https://github.com/aliasfoxkde/Atheon-Enhanced/releases/latest/download/atheon-linux-amd64 - chmod +x atheon-linux-amd64 - sudo mv atheon-linux-amd64 /usr/local/bin/atheon + go install github.com/aliasfoxkde/Atheon/cmd/atheon@v1.5.3-enhanced + echo "$HOME/go/bin" >> "$GITHUB_PATH" - - name: Run Atheon Security Scan (secrets + PII + security) + - name: Run Atheon Security Scan (secrets) run: | - atheon --categories=secrets,pii,security --sarif results.sarif ./ + set +e + atheon --sarif --categories=secrets ./ --no-follow-symlinks > results.raw.sarif + scan_status=$? + set -e + jq 'walk(if type == "object" then del(.kind, .relationships, .shortDescription) else . end)' \ + results.raw.sarif > results.sarif + finding_count=$(jq '[.runs[]?.results[]?] | length' results.sarif) + if [ "$finding_count" -gt 0 ]; then + exit 1 + fi + if [ "$scan_status" -ne 0 ]; then + echo "::warning::Atheon skipped one or more non-text files; no secret findings were reported." + fi - name: Run Atheon Quality Scan + if: always() run: | - atheon --categories=ai-detection,quality --sarif quality-results.sarif ./ || true + set +e + atheon --sarif --categories=ai-detection,quality ./ --no-follow-symlinks > quality.raw.sarif + set -e + jq 'walk(if type == "object" then del(.kind, .relationships, .shortDescription) else . end)' \ + quality.raw.sarif > quality-results.sarif - name: Upload SARIF results to GitHub Security tab uses: github/codeql-action/upload-sarif@v4 @@ -68,16 +89,20 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Install Atheon-Enhanced run: | - wget -q https://github.com/aliasfoxkde/Atheon-Enhanced/releases/latest/download/atheon-linux-amd64 - chmod +x atheon-linux-amd64 - sudo mv atheon-linux-amd64 /usr/local/bin/atheon + go install github.com/aliasfoxkde/Atheon/cmd/atheon@v1.5.3-enhanced + echo "$HOME/go/bin" >> "$GITHUB_PATH" - name: Check for sensitive data in PR run: | git fetch origin ${{ github.base_ref }} --depth=1 - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + CHANGED_FILES=$(git diff --name-only FETCH_HEAD HEAD) if [ -n "$CHANGED_FILES" ]; then atheon --categories=secrets,pii $CHANGED_FILES || { echo "Secrets or PII detected in changed files!" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f5397646..e715d5d0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -12,6 +12,11 @@ on: - '**.go' - '.github/workflows/benchmark.yml' +permissions: + contents: read + issues: write + pull-requests: write + jobs: benchmark: name: Run Benchmarks @@ -191,8 +196,9 @@ jobs: - name: Post benchmark comment if: steps.check-current.outputs.exists == 'true' && steps.fetch-base.outputs.base_exists == 'true' - uses: peaceiris/actions-gh-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: + issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find-comment.outputs.comment_id }} body: | ## Benchmark Results @@ -204,15 +210,15 @@ jobs: - name: Post new benchmark comment (no base) if: steps.check-current.outputs.exists == 'true' && steps.fetch-base.outputs.base_exists != 'true' - uses: peaceiris/actions-gh-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: + issue-number: ${{ github.event.pull_request.number }} body: | ## Benchmark Results First benchmark run on this PR. No previous baseline available for comparison. See artifacts for full benchmark data. - flag: ${{ steps.check-current.outputs.count == '0' && 'empty' || '' }} upload-base: name: Upload Base Benchmark @@ -228,9 +234,9 @@ jobs: continue-on-error: true - name: Upload base benchmark - if: -f benchmark_current.json + if: hashFiles('benchmark_current.json') != '' uses: actions/upload-artifact@v4 with: name: benchmark-base path: benchmark_current.json - retention-days: 90 \ No newline at end of file + retention-days: 90 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14384466..f5788908 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,6 @@ jobs: # ─── Go Build ─────────────────────────────────────────────────────────── build-go: name: Build Go - runs-on: ubuntu-latest if: inputs.has_go == 'true' strategy: fail-fast: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 056c6e0c..b504bd8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: steps: - uses: actions/checkout@v4 - id: detect - run: ${{ github.action_path }}/../actions/detect-languages/detect.sh + run: ./.github/actions/detect-languages/detect.sh # ─── Lint ──────────────────────────────────────────────────────────────── lint: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 874b97f3..3e7d0f00 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -111,6 +111,7 @@ jobs: # ─── No E2E files notice ──────────────────────────────────────────────── no-e2e: name: E2E Tests + needs: check-e2e runs-on: ubuntu-latest if: needs.check-e2e.outputs.has_e2e == 'false' steps: diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 707ff149..9e11cece 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -108,7 +108,7 @@ jobs: strategy: fail-fast: false matrix: - python: ${{ fromJSON(env.PYTHON_VERSIONS) }} + python: ${{ fromJSON(vars.PYTHON_VERSIONS || '["3.11","3.12","3.13"]') }} os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: @@ -140,7 +140,7 @@ jobs: path: .coverage coverage: - needs: test + needs: [check-py-files, test] if: needs.check-py-files.outputs.has_py == 'true' runs-on: ubuntu-latest steps: @@ -166,7 +166,6 @@ jobs: uv run coverage report --fail-under=${{ env.COVERAGE_THRESHOLD }} - name: Upload to Codecov - if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: files: .coverage @@ -234,4 +233,4 @@ jobs: - name: Run E2E tests run: uv run pytest -m e2e --timeout=300 -v -# Required checks: lint, typecheck, test (all python versions), build, vuln, coverage \ No newline at end of file +# Required checks: lint, typecheck, test (all python versions), build, vuln, coverage diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml index 9076001c..3c4db9db 100644 --- a/.github/workflows/release-rust.yml +++ b/.github/workflows/release-rust.yml @@ -75,12 +75,14 @@ jobs: # ─── Build macOS x86_64 + ARM64 ────────────────────────────────────────── build-macos: name: Build macOS - runs-on: macos-latest + runs-on: ${{ matrix.os }} strategy: matrix: - target: - - x86_64-apple-darwin - - aarch64-apple-darwin + include: + - target: x86_64-apple-darwin + os: macos-15-intel + - target: aarch64-apple-darwin + os: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1f754049..9aad45d6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -75,13 +75,20 @@ jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + # Cross-target builds are expected to complete in under 20 minutes; bound + # stalled runners so a provider/runner failure cannot remain in progress + # indefinitely. + timeout-minutes: 20 strategy: matrix: - target: - - x86_64-unknown-linux-gnu - - x86_64-apple-darwin - - x86_64-pc-windows-gnu + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-15-intel + - target: x86_64-pc-windows-gnu + os: windows-latest steps: - name: Checkout repository uses: actions/checkout@v4 @@ -131,7 +138,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Skip cargo-audit when no lockfile is tracked + if: hashFiles('Cargo.lock') == '' + run: echo "No Cargo.lock tracked; skipping Rust dependency audit." + - name: Run cargo-audit + if: hashFiles('Cargo.lock') != '' uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c4bab917..fa753e2d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -222,7 +222,12 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Skip cargo-audit when no lockfile is tracked + if: hashFiles('Cargo.lock') == '' + run: echo "No Cargo.lock tracked; skipping Rust dependency audit." + - name: Run cargo-audit + if: hashFiles('Cargo.lock') != '' uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/setup-repo.yml b/.github/workflows/setup-repo.yml index 2d94f841..9f8e1cbc 100644 --- a/.github/workflows/setup-repo.yml +++ b/.github/workflows/setup-repo.yml @@ -144,14 +144,13 @@ jobs: gh issue create \ --repo "$REPO" \ --title "Repository setup complete" \ - --body "Dark Factory setup complete. Customize the repo by editing: \ -- `.github/CODEOWNERS` (replace `aliasfoxkde` with your username) \ -- `vars.COVERAGE_THRESHOLD` (default: ${{ inputs.coverage_threshold }}%) \ -- `vars.GO_VERSIONS` (default: 1.21-1.24) \ -- `.github/PULL_REQUEST_TEMPLATE.md` \ -- `.github/wiki/` files for your Wiki \ -\ -Delete this issue after customizing." \ + --body "Dark Factory setup complete. Customize the repo by editing:\n\ + - `.github/CODEOWNERS` (replace `aliasfoxkde` with your username)\n\ + - `vars.COVERAGE_THRESHOLD` (default: ${{ inputs.coverage_threshold }}%)\n\ + - `vars.GO_VERSIONS` (default: 1.21-1.24)\n\ + - `.github/PULL_REQUEST_TEMPLATE.md`\n\ + - `.github/wiki/` files for your Wiki\n\ + Delete this issue after customizing." \ --label "documentation" 2>/dev/null || true setup-git-hooks: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f429cdbf..402af9e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - go: ${{ fromJSON(env.GO_VERSIONS) }} + go: ${{ fromJSON(vars.GO_VERSIONS || '["1.21","1.22","1.23","1.24"]') }} os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -87,12 +87,11 @@ jobs: # ─── Python Tests ──────────────────────────────────────────────────────── test-python: name: Test Python - runs-on: ubuntu-latest if: inputs.has_python == 'true' strategy: fail-fast: false matrix: - python: ${{ fromJSON(env.PYTHON_VERSIONS) }} + python: ${{ fromJSON(vars.PYTHON_VERSIONS || '["3.11","3.12","3.13"]') }} os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} env: @@ -131,7 +130,6 @@ jobs: # ─── Rust Tests ───────────────────────────────────────────────────────── test-rust: name: Test Rust - runs-on: ubuntu-latest if: inputs.has_rust == 'true' strategy: fail-fast: false @@ -176,7 +174,6 @@ jobs: # ─── TypeScript Tests ──────────────────────────────────────────────────── test-ts: name: Test TypeScript - runs-on: ubuntu-latest if: inputs.has_typescript == 'true' strategy: fail-fast: false diff --git a/Makefile b/Makefile index 9eb7b94e..d9da2d29 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,6 @@ setup: # ─── Run Services ───────────────────────────────────────────────────────────── run-api: DATABASE_URL="sqlite:///nas/Temp/repos/GitForge/data/gitforge.db?mode=rwc" \ - JWT_SECRET="dev-secret" \ RUST_LOG=info \ ./target/release/api diff --git a/config.toml.example b/config.toml.example index 4d683697..44d48065 100644 --- a/config.toml.example +++ b/config.toml.example @@ -16,14 +16,14 @@ port = 8080 url = "sqlite:/data/gitforge.db" # PostgreSQL for production (recommended) -# url = "postgres://gitforge:password@postgres:5432/gitforge" +# url = "postgres://gitforge:${POSTGRES_PASSWORD}@postgres:5432/gitforge" # ============================================================================= # Authentication # ============================================================================= [auth] # JWT secret - CHANGE THIS IN PRODUCTION! -jwt_secret = "changeme-in-production-min-32-chars" +jwt_secret = "" token_expiry_hours = 24 # ============================================================================= diff --git a/crates/gitforce-api/src/auth.rs b/crates/gitforce-api/src/auth.rs index 3791152e..57cd3acc 100644 --- a/crates/gitforce-api/src/auth.rs +++ b/crates/gitforce-api/src/auth.rs @@ -65,7 +65,12 @@ impl ApiAuth { } /// Generate a JWT token for a user - pub fn generate_token(&self, user_id: UserId, username: &str, role: &str) -> Result { + pub fn generate_token( + &self, + user_id: UserId, + username: &str, + role: &str, + ) -> Result { let claims = Claims::new(user_id, username, role, 24); // 24 hour expiry let token = encode(&Header::default(), &claims, &self.encoding_key) @@ -193,4 +198,4 @@ mod tests { // exp should be iat + 2 hours assert_eq!(claims.exp, claims.iat + (2 * 3600)); } -} \ No newline at end of file +} diff --git a/crates/gitforce-api/src/dashboard.rs b/crates/gitforce-api/src/dashboard.rs index f6630c9f..e8a78bfb 100644 --- a/crates/gitforce-api/src/dashboard.rs +++ b/crates/gitforce-api/src/dashboard.rs @@ -175,8 +175,7 @@ pub async fn dashboard() -> impl IntoResponse { /// Create dashboard routes pub fn dashboard_routes() -> Router { - Router::new() - .route("/dashboard", get(dashboard)) + Router::new().route("/dashboard", get(dashboard)) } #[cfg(test)] diff --git a/crates/gitforce-api/src/lib.rs b/crates/gitforce-api/src/lib.rs index 1223edb2..9bca54ef 100644 --- a/crates/gitforce-api/src/lib.rs +++ b/crates/gitforce-api/src/lib.rs @@ -20,5 +20,5 @@ pub mod server; pub use auth::{ApiAuth, Claims}; pub use metrics::Metrics; -pub use rate_limit::{RateLimiter, RateLimitConfig}; +pub use rate_limit::{RateLimitConfig, RateLimiter}; pub use server::ApiServer; diff --git a/crates/gitforce-api/src/metrics.rs b/crates/gitforce-api/src/metrics.rs index 92eba924..5d242626 100644 --- a/crates/gitforce-api/src/metrics.rs +++ b/crates/gitforce-api/src/metrics.rs @@ -59,7 +59,9 @@ impl Metrics { "http_request_duration_seconds", "HTTP request duration in seconds", ) - .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]), + .buckets(vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, + ]), &["method", "endpoint"], ) .expect("failed to create http_request_duration_seconds histogram"); @@ -75,7 +77,10 @@ impl Metrics { .expect("failed to register repos_total"); let repo_operations_total = IntCounterVec::new( - Opts::new("gitforge_repo_operations_total", "Total repository operations"), + Opts::new( + "gitforge_repo_operations_total", + "Total repository operations", + ), &["operation"], ) .expect("failed to create repo_operations_total counter"); @@ -84,8 +89,9 @@ impl Metrics { .expect("failed to register repo_operations_total"); // CI/CD metrics - let pipelines_total = IntGauge::new("gitforge_pipelines_total", "Total number of pipelines") - .expect("failed to create pipelines_total gauge"); + let pipelines_total = + IntGauge::new("gitforge_pipelines_total", "Total number of pipelines") + .expect("failed to create pipelines_total gauge"); registry .register(Box::new(pipelines_total.clone())) .expect("failed to register pipelines_total"); @@ -106,8 +112,9 @@ impl Metrics { .expect("failed to register jobs_total"); let job_duration_seconds = Histogram::with_opts( - HistogramOpts::new("gitforge_job_duration_seconds", "Job duration in seconds") - .buckets(vec![1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0]), + HistogramOpts::new("gitforge_job_duration_seconds", "Job duration in seconds").buckets( + vec![1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0], + ), ) .expect("failed to create job_duration_seconds histogram"); registry @@ -127,8 +134,9 @@ impl Metrics { .register(Box::new(runners_busy.clone())) .expect("failed to register runners_busy"); - let runners_offline = IntGauge::new("gitforge_runners_offline", "Number of offline runners") - .expect("failed to create runners_offline gauge"); + let runners_offline = + IntGauge::new("gitforge_runners_offline", "Number of offline runners") + .expect("failed to create runners_offline gauge"); registry .register(Box::new(runners_offline.clone())) .expect("failed to register runners_offline"); @@ -160,15 +168,24 @@ impl Metrics { .expect("failed to register events_consumed_total"); // Storage metrics - let artifacts_total = IntGauge::new("gitforge_artifacts_total", "Total number of artifacts") - .expect("failed to create artifacts_total gauge"); + let artifacts_total = + IntGauge::new("gitforge_artifacts_total", "Total number of artifacts") + .expect("failed to create artifacts_total gauge"); registry .register(Box::new(artifacts_total.clone())) .expect("failed to register artifacts_total"); let artifact_size_bytes = Histogram::with_opts( - HistogramOpts::new("gitforge_artifact_size_bytes", "Artifact size in bytes") - .buckets(vec![1024.0, 10240.0, 102400.0, 1048576.0, 10485760.0, 104857600.0]), + HistogramOpts::new("gitforge_artifact_size_bytes", "Artifact size in bytes").buckets( + vec![ + 1024.0, + 10240.0, + 102400.0, + 1048576.0, + 10485760.0, + 104857600.0, + ], + ), ) .expect("failed to create artifact_size_bytes histogram"); registry @@ -219,9 +236,7 @@ impl Metrics { /// Increment pipeline run counter pub fn record_pipeline_run(&self, status: &str) { - self.pipeline_runs_total - .with_label_values(&[status]) - .inc(); + self.pipeline_runs_total.with_label_values(&[status]).inc(); } /// Record job duration diff --git a/crates/gitforce-api/src/metrics_middleware.rs b/crates/gitforce-api/src/metrics_middleware.rs index 1ae62f64..181ecaf1 100644 --- a/crates/gitforce-api/src/metrics_middleware.rs +++ b/crates/gitforce-api/src/metrics_middleware.rs @@ -3,10 +3,7 @@ //! Intercepts all HTTP requests and records metrics for Prometheus export. use crate::metrics::Metrics; -use axum::{ - extract::Request, - response::Response, -}; +use axum::{extract::Request, response::Response}; use std::{ convert::Infallible, future::Future, @@ -160,18 +157,12 @@ mod tests { #[test] fn test_normalize_path_with_numeric_id() { - assert_eq!( - normalize_path("/api/repos/12345"), - "/api/repos/{id}" - ); + assert_eq!(normalize_path("/api/repos/12345"), "/api/repos/{id}"); } #[test] fn test_normalize_path_preserves_static_segments() { - assert_eq!( - normalize_path("/api/repos"), - "/api/repos" - ); + assert_eq!(normalize_path("/api/repos"), "/api/repos"); } #[test] diff --git a/crates/gitforce-api/src/middleware.rs b/crates/gitforce-api/src/middleware.rs index 4551750e..5a21f975 100644 --- a/crates/gitforce-api/src/middleware.rs +++ b/crates/gitforce-api/src/middleware.rs @@ -11,13 +11,7 @@ use axum::{ use std::sync::Arc; /// Paths that don't require authentication -const PUBLIC_PATHS: &[&str] = &[ - "/health", - "/metrics", - "/swagger-ui", - "/api-docs", - "/docs", -]; +const PUBLIC_PATHS: &[&str] = &["/health", "/metrics", "/swagger-ui", "/api-docs", "/docs"]; /// Error response for auth failures #[derive(Debug, serde::Serialize)] @@ -51,7 +45,10 @@ pub async fn auth_middleware( Some(header) => match crate::auth::ApiAuth::extract_token(header) { Some(token) => token, None => { - return auth_error_response("missing_token", "Missing or invalid Authorization header"); + return auth_error_response( + "missing_token", + "Missing or invalid Authorization header", + ); } }, None => { @@ -76,10 +73,14 @@ pub async fn auth_middleware( /// Create an authentication error response fn auth_error_response(error: &str, message: &str) -> Response { - (StatusCode::UNAUTHORIZED, Json(AuthErrorResponse { - error: error.to_string(), - message: message.to_string(), - })).into_response() + ( + StatusCode::UNAUTHORIZED, + Json(AuthErrorResponse { + error: error.to_string(), + message: message.to_string(), + }), + ) + .into_response() } /// Extractor for authenticated user claims @@ -96,16 +97,16 @@ impl FromRequestParts for AuthenticatedUser { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let claims = parts - .extensions - .get::() - .cloned() - .ok_or_else(|| { - (StatusCode::UNAUTHORIZED, Json(AuthErrorResponse { + let claims = parts.extensions.get::().cloned().ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(AuthErrorResponse { error: "unauthenticated".to_string(), message: "No authentication context".to_string(), - })).into_response() - })?; + }), + ) + .into_response() + })?; Ok(AuthenticatedUser { claims }) } @@ -226,8 +227,12 @@ mod tests { #[test] fn test_public_paths_exact_match() { // Test that paths that are exactly the public path work - assert!(PUBLIC_PATHS.iter().any(|p| "/health" == *p || "/health".starts_with(p))); - assert!(PUBLIC_PATHS.iter().any(|p| "/metrics" == *p || "/metrics".starts_with(p))); + assert!(PUBLIC_PATHS + .iter() + .any(|p| "/health" == *p || "/health".starts_with(p))); + assert!(PUBLIC_PATHS + .iter() + .any(|p| "/metrics" == *p || "/metrics".starts_with(p))); } #[test] diff --git a/crates/gitforce-api/src/openapi.rs b/crates/gitforce-api/src/openapi.rs index 9a05e9c5..4a8dcfa3 100644 --- a/crates/gitforce-api/src/openapi.rs +++ b/crates/gitforce-api/src/openapi.rs @@ -2,11 +2,7 @@ //! //! Interactive API documentation available at /swagger-ui when server is running. -use axum::{ - response::IntoResponse, - routing::get, - Json, Router, -}; +use axum::{response::IntoResponse, routing::get, Json, Router}; use serde_json::json; /// OpenAPI 3.0 specification for GitForge API @@ -529,7 +525,9 @@ mod tests { async fn test_swagger_ui_returns_html() { let response = swagger_ui().await.into_response(); assert_eq!(response.status(), axum::http::StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); let body_str = String::from_utf8(body.to_vec()).unwrap(); assert!(body_str.contains("swagger-ui")); assert!(body_str.contains("GitForge API")); diff --git a/crates/gitforce-api/src/rate_limit.rs b/crates/gitforce-api/src/rate_limit.rs index 55ca3164..1c0ac4e4 100644 --- a/crates/gitforce-api/src/rate_limit.rs +++ b/crates/gitforce-api/src/rate_limit.rs @@ -101,7 +101,9 @@ impl RateLimiter { let clients = self.clients.read().await; if let Some(client) = clients.get(client_id) { - let elapsed = Instant::now().duration_since(client.last_update).as_secs_f64(); + let elapsed = Instant::now() + .duration_since(client.last_update) + .as_secs_f64(); let tokens = (client.tokens + elapsed * rate).min(self.config.burst_size as f64); tokens as u64 } else { @@ -142,15 +144,23 @@ impl RateLimitMiddleware { impl tower::Service> for RateLimitMiddleware where - S: tower::Service, Response = Response, Error = std::convert::Infallible> + Clone + Send + 'static, + S: tower::Service, Response = Response, Error = std::convert::Infallible> + + Clone + + Send + + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = Response; type Error = std::convert::Infallible; - type Future = std::pin::Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll> { + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { self.inner.poll_ready(cx) } @@ -172,7 +182,8 @@ where message: "Too many requests. Please try again later.".to_string(), retry_after_secs: 60, }), - ).into_response(); + ) + .into_response(); return Ok(response); } @@ -208,8 +219,18 @@ fn extract_client_id(request: &Request) -> String { .headers() .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) - .or_else(|| request.headers().get("x-real-ip").and_then(|v| v.to_str().ok())) - .or_else(|| request.headers().get("cf-connecting-ip").and_then(|v| v.to_str().ok())) + .or_else(|| { + request + .headers() + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + }) + .or_else(|| { + request + .headers() + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + }) .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); diff --git a/crates/gitforce-api/src/routes/artifacts.rs b/crates/gitforce-api/src/routes/artifacts.rs index 576cc953..0a537c7d 100644 --- a/crates/gitforce-api/src/routes/artifacts.rs +++ b/crates/gitforce-api/src/routes/artifacts.rs @@ -36,9 +36,7 @@ pub fn artifact_routes() -> Router { /// Helper to extract and validate user from headers fn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> { - let auth_header = headers - .get("Authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); let token = auth_header .and_then(|h| ApiAuth::extract_token(h)) @@ -75,18 +73,20 @@ async fn list_artifacts( tracing::debug!("list artifacts"); match storage.list().await { Ok(artifacts) => { - let responses: Vec = artifacts - .iter() - .map(artifact_to_response) - .collect(); + let responses: Vec = + artifacts.iter().map(artifact_to_response).collect(); Json(responses).into_response() } Err(e) => { tracing::error!("failed to list artifacts: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: "storage_error".to_string(), - message: e.to_string(), - })).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "storage_error".to_string(), + message: e.to_string(), + }), + ) + .into_response() } } } @@ -108,10 +108,14 @@ async fn get_artifact( let artifact_id = match Uuid::parse_str(&id) { Ok(uuid) => ArtifactId::from(uuid), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(ErrorResponse { - error: "invalid_id".to_string(), - message: "Invalid artifact ID format".to_string(), - })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "invalid_id".to_string(), + message: "Invalid artifact ID format".to_string(), + }), + ) + .into_response(); } }; @@ -122,10 +126,14 @@ async fn get_artifact( } Err(e) => { tracing::error!("failed to get artifact metadata: {}", e); - (StatusCode::NOT_FOUND, Json(ErrorResponse { - error: "not_found".to_string(), - message: "Artifact not found".to_string(), - })).into_response() + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "not_found".to_string(), + message: "Artifact not found".to_string(), + }), + ) + .into_response() } } } @@ -147,10 +155,14 @@ async fn delete_artifact( let artifact_id = match Uuid::parse_str(&id) { Ok(uuid) => ArtifactId::from(uuid), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(ErrorResponse { - error: "invalid_id".to_string(), - message: "Invalid artifact ID format".to_string(), - })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "invalid_id".to_string(), + message: "Invalid artifact ID format".to_string(), + }), + ) + .into_response(); } }; @@ -158,10 +170,14 @@ async fn delete_artifact( Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { tracing::error!("failed to delete artifact: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: "storage_error".to_string(), - message: e.to_string(), - })).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "storage_error".to_string(), + message: e.to_string(), + }), + ) + .into_response() } } } @@ -183,27 +199,33 @@ async fn get_job_artifacts( let job_id_val = match Uuid::parse_str(&job_id) { Ok(uuid) => gitforce_common::JobId::from(uuid), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(ErrorResponse { - error: "invalid_id".to_string(), - message: "Invalid job ID format".to_string(), - })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "invalid_id".to_string(), + message: "Invalid job ID format".to_string(), + }), + ) + .into_response(); } }; match storage.list_by_job(job_id_val).await { Ok(artifacts) => { - let responses: Vec = artifacts - .iter() - .map(artifact_to_response) - .collect(); + let responses: Vec = + artifacts.iter().map(artifact_to_response).collect(); Json(responses).into_response() } Err(e) => { tracing::error!("failed to list artifacts for job: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: "storage_error".to_string(), - message: e.to_string(), - })).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "storage_error".to_string(), + message: e.to_string(), + }), + ) + .into_response() } } } @@ -323,7 +345,11 @@ mod tests { #[test] fn test_artifact_response_various_timestamps() { - for ts in &["2024-01-01T00:00:00Z", "2025-12-31T23:59:59Z", "2026-07-16T12:00:00Z"] { + for ts in &[ + "2024-01-01T00:00:00Z", + "2025-12-31T23:59:59Z", + "2026-07-16T12:00:00Z", + ] { let response = ArtifactResponse { id: "art-ts".to_string(), job_id: "job-ts".to_string(), diff --git a/crates/gitforce-api/src/routes/ci.rs b/crates/gitforce-api/src/routes/ci.rs index e2d0b7b1..e686fc8c 100644 --- a/crates/gitforce-api/src/routes/ci.rs +++ b/crates/gitforce-api/src/routes/ci.rs @@ -9,7 +9,10 @@ use axum::{ Json, Router, }; use gitforce_common::{JobId, PipelineId, PipelineRunId}; -use gitforce_db::{Pool, queries::{PipelineQueries, PipelineRunQueries, JobQueries}}; +use gitforce_db::{ + queries::{JobQueries, PipelineQueries, PipelineRunQueries}, + Pool, +}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -51,9 +54,7 @@ pub fn ci_routes() -> Router { /// Helper to extract and validate user from headers fn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> { - let auth_header = headers - .get("Authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); let token = auth_header .and_then(|h| ApiAuth::extract_token(h)) @@ -73,10 +74,11 @@ async fn list_pipelines( ) -> impl IntoResponse { match extract_user(&auth, &headers) { Err(e) => e.into_response(), - Ok(_) => { - match PipelineQueries::list(&pool).await { - Ok(pipelines) => { - let response: Vec = pipelines.into_iter().map(|p| { + Ok(_) => match PipelineQueries::list(&pool).await { + Ok(pipelines) => { + let response: Vec = pipelines + .into_iter() + .map(|p| { serde_json::json!({ "id": p.id.to_string(), "repo_id": p.repo_id.to_string(), @@ -84,15 +86,15 @@ async fn list_pipelines( "trigger_type": p.trigger_type, "created_at": p.created_at.to_rfc3339() }) - }).collect(); - Json(response).into_response() - } - Err(e) => { - tracing::error!("failed to list pipelines: {}", e); - Json(serde_json::Value::Array(vec![])).into_response() - } + }) + .collect(); + Json(response).into_response() } - } + Err(e) => { + tracing::error!("failed to list pipelines: {}", e); + Json(serde_json::Value::Array(vec![])).into_response() + } + }, } } @@ -112,36 +114,46 @@ async fn get_pipeline( Ok(uuid) => { let pipeline_id = PipelineId::from(uuid); match PipelineQueries::get(&pool, pipeline_id).await { - Ok(Some(pipeline)) => { - (StatusCode::OK, Json(serde_json::json!({ + Ok(Some(pipeline)) => ( + StatusCode::OK, + Json(serde_json::json!({ "id": pipeline.id.to_string(), "repo_id": pipeline.repo_id.to_string(), "name": pipeline.name, "trigger_type": pipeline.trigger_type, "created_at": pipeline.created_at.to_rfc3339() - }))).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not_found", "message": "Pipeline not found" - }))).into_response() - } + })), + ) + .into_response(), Err(e) => { tracing::error!("failed to get pipeline: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to get pipeline: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to get pipeline: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid pipeline ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } @@ -155,10 +167,11 @@ async fn list_pipeline_runs( ) -> impl IntoResponse { match extract_user(&auth, &headers) { Err(e) => e.into_response(), - Ok(_) => { - match PipelineRunQueries::list(&pool).await { - Ok(runs) => { - let response: Vec = runs.into_iter().map(|r| { + Ok(_) => match PipelineRunQueries::list(&pool).await { + Ok(runs) => { + let response: Vec = runs + .into_iter() + .map(|r| { serde_json::json!({ "id": r.id.to_string(), "pipeline_id": r.pipeline_id.to_string(), @@ -168,15 +181,15 @@ async fn list_pipeline_runs( "started_at": r.started_at.map(|dt| dt.to_rfc3339()), "finished_at": r.finished_at.map(|dt| dt.to_rfc3339()) }) - }).collect(); - Json(response).into_response() - } - Err(e) => { - tracing::error!("failed to list pipeline runs: {}", e); - Json(serde_json::Value::Array(vec![])).into_response() - } + }) + .collect(); + Json(response).into_response() } - } + Err(e) => { + tracing::error!("failed to list pipeline runs: {}", e); + Json(serde_json::Value::Array(vec![])).into_response() + } + }, } } @@ -196,8 +209,9 @@ async fn get_pipeline_run( Ok(uuid) => { let run_id = PipelineRunId::from(uuid); match PipelineRunQueries::get(&pool, run_id).await { - Ok(Some(run)) => { - (StatusCode::OK, Json(serde_json::json!({ + Ok(Some(run)) => ( + StatusCode::OK, + Json(serde_json::json!({ "id": run.id.to_string(), "pipeline_id": run.pipeline_id.to_string(), "status": run.status, @@ -205,29 +219,38 @@ async fn get_pipeline_run( "triggered_by": run.triggered_by, "started_at": run.started_at.map(|dt| dt.to_rfc3339()), "finished_at": run.finished_at.map(|dt| dt.to_rfc3339()) - }))).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not_found", "message": "Pipeline run not found" - }))).into_response() - } + })), + ) + .into_response(), Err(e) => { tracing::error!("failed to get pipeline run: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to get pipeline run: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to get pipeline run: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid pipeline run ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } @@ -250,33 +273,42 @@ async fn get_pipeline_run_jobs( let run_id = PipelineRunId::from(uuid); match JobQueries::list_by_run(&pool, run_id).await { Ok(jobs) => { - let jobs_json: Vec = jobs.into_iter().map(|j| { - serde_json::json!({ - "id": j.id.to_string(), - "name": j.name, - "status": j.status, - "runner_id": j.runner_id.map(|id| id.to_string()), - "started_at": j.started_at.map(|dt| dt.to_rfc3339()), - "finished_at": j.finished_at.map(|dt| dt.to_rfc3339()) + let jobs_json: Vec = jobs + .into_iter() + .map(|j| { + serde_json::json!({ + "id": j.id.to_string(), + "name": j.name, + "status": j.status, + "runner_id": j.runner_id.map(|id| id.to_string()), + "started_at": j.started_at.map(|dt| dt.to_rfc3339()), + "finished_at": j.finished_at.map(|dt| dt.to_rfc3339()) + }) }) - }).collect(); + .collect(); Json(jobs_json).into_response() } Err(e) => { tracing::error!("failed to list jobs: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to list jobs: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to list jobs: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid pipeline run ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } @@ -298,37 +330,47 @@ async fn get_job( Ok(uuid) => { let job_id = JobId::from(uuid); match JobQueries::get(&pool, job_id).await { - Ok(Some(job)) => { - (StatusCode::OK, Json(serde_json::json!({ + Ok(Some(job)) => ( + StatusCode::OK, + Json(serde_json::json!({ "id": job.id.to_string(), "name": job.name, "status": job.status, "runner_id": job.runner_id.map(|id| id.to_string()), "started_at": job.started_at.map(|dt| dt.to_rfc3339()), "finished_at": job.finished_at.map(|dt| dt.to_rfc3339()) - }))).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not_found", "message": "Job not found" - }))).into_response() - } + })), + ) + .into_response(), Err(e) => { tracing::error!("failed to get job: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to get job: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to get job: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid job ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } @@ -344,10 +386,14 @@ async fn get_job_logs( Err(e) => e.into_response(), Ok(_) => { tracing::debug!("get job logs: {}", id); - (StatusCode::OK, Json(serde_json::json!({ - "job_id": id, - "logs": "Logs not yet implemented - coming soon" - }))).into_response() + ( + StatusCode::OK, + Json(serde_json::json!({ + "job_id": id, + "logs": "Logs not yet implemented - coming soon" + })), + ) + .into_response() } } } diff --git a/crates/gitforce-api/src/routes/repo.rs b/crates/gitforce-api/src/routes/repo.rs index 069f5806..233bedc0 100644 --- a/crates/gitforce-api/src/routes/repo.rs +++ b/crates/gitforce-api/src/routes/repo.rs @@ -9,7 +9,7 @@ use axum::{ Json, Router, }; use gitforce_common::{RepoId, UserId}; -use gitforce_db::{Pool, queries::RepoQueries}; +use gitforce_db::{queries::RepoQueries, Pool}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -41,10 +41,16 @@ fn validate_repo_name(name: &str) -> Result<(), String> { } // Check for special characters that could be problematic - let invalid_chars = ['\0', '\n', '\r', '\t', '\\', '"', '\'', '`', '>', '<', '|', '&', ';', '$', '!', '{', '}', '[', ']', '(', ')']; + let invalid_chars = [ + '\0', '\n', '\r', '\t', '\\', '"', '\'', '`', '>', '<', '|', '&', ';', '$', '!', '{', '}', + '[', ']', '(', ')', + ]; for c in invalid_chars { if name.contains(c) { - return Err(format!("Repository name contains invalid character: {:?}", c)); + return Err(format!( + "Repository name contains invalid character: {:?}", + c + )); } } @@ -88,15 +94,14 @@ pub fn repo_routes() -> Router { /// Helper to extract and validate user from headers fn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result { - let auth_header = headers - .get("Authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); let token = auth_header .and_then(|h| ApiAuth::extract_token(h)) .ok_or(StatusCode::UNAUTHORIZED)?; - let claims = auth.validate_token(token) + let claims = auth + .validate_token(token) .map_err(|_| StatusCode::UNAUTHORIZED)?; Ok(claims.user_id) @@ -111,10 +116,11 @@ async fn list_repos( // Check auth match extract_user(&auth, &headers) { Err(e) => e.into_response(), - Ok(_) => { - match RepoQueries::list(&pool).await { - Ok(repos) => { - let response: Vec = repos.into_iter().map(|r| RepoResponse { + Ok(_) => match RepoQueries::list(&pool).await { + Ok(repos) => { + let response: Vec = repos + .into_iter() + .map(|r| RepoResponse { id: r.id.to_string(), name: r.name, owner_id: r.owner_id.to_string(), @@ -122,15 +128,15 @@ async fn list_repos( git_path: r.git_path, created_at: r.created_at.to_rfc3339(), updated_at: r.updated_at.to_rfc3339(), - }).collect(); - Json(response).into_response() - } - Err(e) => { - tracing::error!("failed to list repos: {}", e); - Json(serde_json::Value::Array(vec![])).into_response() - } + }) + .collect(); + Json(response).into_response() } - } + Err(e) => { + tracing::error!("failed to list repos: {}", e); + Json(serde_json::Value::Array(vec![])).into_response() + } + }, } } @@ -151,19 +157,19 @@ async fn create_repo( // Validate repository name to prevent path traversal and injection if let Err(e) = validate_repo_name(&req.name) { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid_name", - "message": e - }))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_name", + "message": e + })), + ) + .into_response(); } let git_path = format!("/git/repos/{}", req.name); - let repo = gitforce_db::models::Repository::new( - req.name, - owner_id, - git_path.clone(), - ); + let repo = gitforce_db::models::Repository::new(req.name, owner_id, git_path.clone()); match RepoQueries::create(&pool, &repo).await { Ok(_) => { @@ -180,10 +186,14 @@ async fn create_repo( } Err(e) => { tracing::error!("failed to create repo: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to create repository: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to create repository: {}", e) + })), + ) + .into_response() } } } @@ -217,27 +227,35 @@ async fn get_repo( }; (StatusCode::OK, Json(response)).into_response() } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not_found", "message": "Repository not found" - }))).into_response() - } + })), + ) + .into_response(), Err(e) => { tracing::error!("failed to get repo: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to get repository: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to get repository: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid repository ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } @@ -263,19 +281,25 @@ async fn delete_repo( Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { tracing::error!("failed to delete repo: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to delete repository: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to delete repository: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid repository ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } diff --git a/crates/gitforce-api/src/routes/runners.rs b/crates/gitforce-api/src/routes/runners.rs index 352fe38b..573e7879 100644 --- a/crates/gitforce-api/src/routes/runners.rs +++ b/crates/gitforce-api/src/routes/runners.rs @@ -9,7 +9,7 @@ use axum::{ Json, Router, }; use gitforce_common::RunnerId; -use gitforce_db::{Pool, queries::RunnerQueries, models::RunnerType}; +use gitforce_db::{models::RunnerType, queries::RunnerQueries, Pool}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -35,9 +35,7 @@ pub fn runner_routes() -> Router { /// Helper to extract and validate user from headers fn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> { - let auth_header = headers - .get("Authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); let token = auth_header .and_then(|h| ApiAuth::extract_token(h)) @@ -57,25 +55,26 @@ async fn list_runners( ) -> impl IntoResponse { match extract_user(&auth, &headers) { Err(e) => e.into_response(), - Ok(_) => { - match RunnerQueries::list(&pool).await { - Ok(runners) => { - let response: Vec = runners.into_iter().map(|r| RunnerResponse { + Ok(_) => match RunnerQueries::list(&pool).await { + Ok(runners) => { + let response: Vec = runners + .into_iter() + .map(|r| RunnerResponse { id: r.id.to_string(), name: r.name, runner_type: r.runner_type, status: r.status, capacity: r.capacity, last_heartbeat: r.last_heartbeat.map(|dt| dt.to_rfc3339()), - }).collect(); - Json(response).into_response() - } - Err(e) => { - tracing::error!("failed to list runners: {}", e); - Json(serde_json::Value::Array(vec![])).into_response() - } + }) + .collect(); + Json(response).into_response() } - } + Err(e) => { + tracing::error!("failed to list runners: {}", e); + Json(serde_json::Value::Array(vec![])).into_response() + } + }, } } @@ -113,10 +112,14 @@ async fn register_runner( } Err(e) => { tracing::error!("failed to register runner: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to register runner: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to register runner: {}", e) + })), + ) + .into_response() } } } @@ -137,37 +140,47 @@ async fn get_runner( Ok(uuid) => { let runner_id = RunnerId::from(uuid); match RunnerQueries::get(&pool, runner_id).await { - Ok(Some(runner)) => { - (StatusCode::OK, Json(RunnerResponse { + Ok(Some(runner)) => ( + StatusCode::OK, + Json(RunnerResponse { id: runner.id.to_string(), name: runner.name, runner_type: runner.runner_type, status: runner.status, capacity: runner.capacity, last_heartbeat: runner.last_heartbeat.map(|dt| dt.to_rfc3339()), - })).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ + }), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "not_found", "message": "Runner not found" - }))).into_response() - } + })), + ) + .into_response(), Err(e) => { tracing::error!("failed to get runner: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database_error", - "message": format!("failed to get runner: {}", e) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "database_error", + "message": format!("failed to get runner: {}", e) + })), + ) + .into_response() } } } - Err(_) => { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + Err(_) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid_id", "message": "Invalid runner ID format" - }))).into_response() - } + })), + ) + .into_response(), } } } diff --git a/crates/gitforce-api/src/routes/webhook.rs b/crates/gitforce-api/src/routes/webhook.rs index 39851e2e..03008e24 100644 --- a/crates/gitforce-api/src/routes/webhook.rs +++ b/crates/gitforce-api/src/routes/webhook.rs @@ -11,7 +11,7 @@ use axum::{ Json, Router, }; use gitforce_common::PipelineId; -use gitforce_db::{Pool, queries::PipelineQueries}; +use gitforce_db::{queries::PipelineQueries, Pool}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -38,15 +38,12 @@ pub struct WebhookTriggerResponse { /// Webhook routes pub fn webhook_routes() -> Router { - Router::new() - .route("/webhook/trigger/:pipeline_id", post(trigger_pipeline)) + Router::new().route("/webhook/trigger/:pipeline_id", post(trigger_pipeline)) } /// Helper to extract and validate user from headers fn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> { - let auth_header = headers - .get("Authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); let token = auth_header .and_then(|h| ApiAuth::extract_token(h)) @@ -71,17 +68,25 @@ async fn trigger_pipeline( return e.into_response(); } - tracing::info!("Webhook trigger for pipeline {} from repo {}", pipeline_id, payload.repo_id); + tracing::info!( + "Webhook trigger for pipeline {} from repo {}", + pipeline_id, + payload.repo_id + ); // Parse pipeline ID let pipeline_uuid = match uuid::Uuid::parse_str(&pipeline_id) { Ok(uuid) => PipelineId::from(uuid), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(WebhookTriggerResponse { - success: false, - message: "Invalid pipeline ID format".to_string(), - pipeline_id: None, - })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(WebhookTriggerResponse { + success: false, + message: "Invalid pipeline ID format".to_string(), + pipeline_id: None, + }), + ) + .into_response(); } }; @@ -90,29 +95,43 @@ async fn trigger_pipeline( Ok(Some(_pipeline)) => { // Pipeline exists - in a full implementation, this would // create a new pipeline run and return its ID - tracing::info!("Triggering pipeline {} for repo {} at commit {}", - pipeline_id, payload.repo_id, payload.commit_hash); - - (StatusCode::OK, Json(WebhookTriggerResponse { - success: true, - message: format!("Pipeline triggered for branch '{}'", payload.branch), - pipeline_id: Some(pipeline_id), - })).into_response() + tracing::info!( + "Triggering pipeline {} for repo {} at commit {}", + pipeline_id, + payload.repo_id, + payload.commit_hash + ); + + ( + StatusCode::OK, + Json(WebhookTriggerResponse { + success: true, + message: format!("Pipeline triggered for branch '{}'", payload.branch), + pipeline_id: Some(pipeline_id), + }), + ) + .into_response() } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(WebhookTriggerResponse { + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(WebhookTriggerResponse { success: false, message: "Pipeline not found".to_string(), pipeline_id: None, - })).into_response() - } + }), + ) + .into_response(), Err(e) => { tracing::error!("Failed to get pipeline: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(WebhookTriggerResponse { - success: false, - message: "Database error".to_string(), - pipeline_id: None, - })).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(WebhookTriggerResponse { + success: false, + message: "Database error".to_string(), + pipeline_id: None, + }), + ) + .into_response() } } } diff --git a/crates/gitforce-api/src/server.rs b/crates/gitforce-api/src/server.rs index 8cfb6ba4..9dfbac77 100644 --- a/crates/gitforce-api/src/server.rs +++ b/crates/gitforce-api/src/server.rs @@ -5,11 +5,7 @@ use crate::metrics::Metrics; use crate::metrics_middleware::MetricsLayer; use crate::routes::{artifact_routes, ci_routes, repo_routes, runner_routes, webhook_routes}; use axum::{ - extract::Extension, - http::StatusCode, - response::IntoResponse, - routing::get, - Json, Router, + extract::Extension, http::StatusCode, response::IntoResponse, routing::get, Json, Router, }; use gitforce_db::Pool; use gitforce_storage::FileStorage; @@ -31,7 +27,11 @@ impl ApiServer { } /// Create a new API server with custom storage path - pub fn with_storage(jwt_secret: &str, pool: Pool, _storage_path: Option) -> Self { + pub fn with_storage( + jwt_secret: &str, + pool: Pool, + _storage_path: Option, + ) -> Self { let auth = ApiAuth::new(jwt_secret); let metrics = Metrics::new(); let metrics_arc = Arc::new(metrics); @@ -77,13 +77,19 @@ impl ApiServer { .layer(Extension(metrics_arc)) .nest("/api", protected_routes); - Self { router: app, port: 8080 } + Self { + router: app, + port: 8080, + } } /// Add storage extension to the router pub fn with_storage_extension(self, storage: Arc) -> Self { let app = self.router.layer(Extension(storage)); - Self { router: app, ..self } + Self { + router: app, + ..self + } } /// Set the port @@ -114,9 +120,7 @@ impl ApiServer { } /// Health check endpoint -async fn health_check( - Extension(pool): Extension>, -) -> impl IntoResponse { +async fn health_check(Extension(pool): Extension>) -> impl IntoResponse { let db_status = match pool.health_check().await { Ok(_) => "connected", Err(e) => { @@ -125,7 +129,11 @@ async fn health_check( } }; - let overall_status = if db_status == "connected" { "healthy" } else { "unhealthy" }; + let overall_status = if db_status == "connected" { + "healthy" + } else { + "unhealthy" + }; Json(HealthResponse { status: overall_status.to_string(), @@ -143,9 +151,7 @@ pub struct HealthResponse { } /// Metrics endpoint handler -async fn metrics_handler( - Extension(metrics): Extension>, -) -> impl IntoResponse { +async fn metrics_handler(Extension(metrics): Extension>) -> impl IntoResponse { use prometheus::Encoder; let encoder = prometheus::TextEncoder::new(); let metric_families = metrics.registry.gather(); @@ -165,10 +171,13 @@ pub struct ErrorResponse { /// Helper to create an error response pub fn error_response(status: StatusCode, error: &str, message: &str) -> impl IntoResponse { - (status, Json(ErrorResponse { - error: error.to_string(), - message: message.to_string(), - })) + ( + status, + Json(ErrorResponse { + error: error.to_string(), + message: message.to_string(), + }), + ) } #[cfg(test)] @@ -186,7 +195,9 @@ mod tests { async fn test_health_check() { let pool = Pool::memory().await.unwrap(); pool.migrate().await.unwrap(); - let response = health_check(Extension(Arc::new(pool))).await.into_response(); + let response = health_check(Extension(Arc::new(pool))) + .await + .into_response(); assert_eq!(response.status(), StatusCode::OK); } @@ -194,9 +205,14 @@ mod tests { async fn test_health_check_no_migration() { let pool = Pool::memory().await.unwrap(); // Don't migrate - health check should still work - let response = health_check(Extension(Arc::new(pool))).await.into_response(); + let response = health_check(Extension(Arc::new(pool))) + .await + .into_response(); // May return unhealthy but shouldn't panic - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -250,7 +266,11 @@ mod tests { #[tokio::test] async fn test_server_with_storage() { let pool = Pool::memory().await.unwrap(); - let server = ApiServer::with_storage("test-secret", pool, Some(std::path::PathBuf::from("/tmp/storage"))); + let server = ApiServer::with_storage( + "test-secret", + pool, + Some(std::path::PathBuf::from("/tmp/storage")), + ); assert_eq!(server.port, 8080); } diff --git a/crates/gitforce-api/tests/integration.rs b/crates/gitforce-api/tests/integration.rs index ff507cc9..03696c09 100644 --- a/crates/gitforce-api/tests/integration.rs +++ b/crates/gitforce-api/tests/integration.rs @@ -2,13 +2,13 @@ //! //! These tests verify API functionality with database integration. -use gitforce_api::{ApiAuth, ApiServer}; -use gitforce_db::Pool; -use gitforce_storage::FileStorage; use axum::{ body::Body, http::{Request, StatusCode}, }; +use gitforce_api::{ApiAuth, ApiServer}; +use gitforce_db::Pool; +use gitforce_storage::FileStorage; use std::sync::Arc; use tower::ServiceExt; @@ -30,7 +30,12 @@ async fn test_health_check_endpoint() { // Make request to health endpoint let response = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -45,7 +50,12 @@ async fn test_metrics_endpoint() { // Request metrics let response = app - .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/metrics") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -59,7 +69,12 @@ async fn test_swagger_ui_endpoint() { let app = server.into_router(); let response = app - .oneshot(Request::builder().uri("/swagger-ui").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/swagger-ui") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -74,7 +89,12 @@ async fn test_openapi_spec_endpoint() { let app = server.into_router(); let response = app - .oneshot(Request::builder().uri("/api-docs/openapi.json").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api-docs/openapi.json") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -135,7 +155,9 @@ async fn test_auth_with_expired_claims() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -170,7 +192,10 @@ async fn test_auth_with_expired_claims() { // Expired tokens should be rejected with 401 // (Note: May return 500 if queries are not fully implemented) - assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::UNAUTHORIZED + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -192,7 +217,10 @@ async fn test_protected_route_without_auth_returns_error() { .unwrap(); // Should get UNAUTHORIZED or INTERNAL_SERVER_ERROR (if auth check passes but DB fails) - assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::UNAUTHORIZED + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -206,7 +234,9 @@ async fn test_create_repo_with_valid_auth() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -230,7 +260,10 @@ async fn test_create_repo_with_valid_auth() { .unwrap(); // Should succeed (201) or fail gracefully (500 if query not implemented) - assert!(response.status() == StatusCode::CREATED || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::CREATED + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -243,7 +276,9 @@ async fn test_get_nonexistent_repo() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -263,7 +298,10 @@ async fn test_get_nonexistent_repo() { .unwrap(); // Should get NOT_FOUND or INTERNAL_SERVER_ERROR (if query not implemented) - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -276,7 +314,9 @@ async fn test_delete_nonexistent_repo() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -297,7 +337,11 @@ async fn test_delete_nonexistent_repo() { .unwrap(); // Should get NOT_FOUND or INTERNAL_SERVER_ERROR (if query not implemented) - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::NO_CONTENT || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::NO_CONTENT + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -307,7 +351,12 @@ async fn test_dashboard_endpoint() { let app = server.into_router(); let response = app - .oneshot(Request::builder().uri("/dashboard").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/dashboard") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -324,7 +373,9 @@ async fn test_api_pipeline_runs_endpoint() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -347,8 +398,8 @@ async fn test_api_pipeline_runs_endpoint() { let status = response.status(); assert!( status == StatusCode::OK - || status == StatusCode::INTERNAL_SERVER_ERROR - || status == StatusCode::NOT_FOUND, + || status == StatusCode::INTERNAL_SERVER_ERROR + || status == StatusCode::NOT_FOUND, "Unexpected status: {}", status ); @@ -364,13 +415,14 @@ async fn test_api_artifacts_endpoint() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let auth = ApiAuth::new("test-secret"); @@ -388,7 +440,10 @@ async fn test_api_artifacts_endpoint() { .unwrap(); // Should get OK or INTERNAL_SERVER_ERROR (if query not implemented) - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -401,7 +456,9 @@ async fn test_api_runners_endpoint() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -421,7 +478,10 @@ async fn test_api_runners_endpoint() { .unwrap(); // Should get OK or error - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -434,13 +494,14 @@ async fn test_api_get_nonexistent_artifact() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let auth = ApiAuth::new("test-secret"); @@ -458,7 +519,10 @@ async fn test_api_get_nonexistent_artifact() { .unwrap(); // Should get NOT_FOUND since artifact doesn't exist - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -471,13 +535,14 @@ async fn test_api_delete_nonexistent_artifact() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let auth = ApiAuth::new("test-secret"); @@ -496,7 +561,10 @@ async fn test_api_delete_nonexistent_artifact() { .unwrap(); // Should get NOT_FOUND since artifact doesn't exist - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -509,13 +577,14 @@ async fn test_api_artifact_invalid_id() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let auth = ApiAuth::new("test-secret"); @@ -533,7 +602,9 @@ async fn test_api_artifact_invalid_id() { .unwrap(); // Should get BAD_REQUEST for invalid UUID or NOT_FOUND (route not matched) - assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -546,13 +617,14 @@ async fn test_api_job_artifacts_empty() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let auth = ApiAuth::new("test-secret"); @@ -570,7 +642,11 @@ async fn test_api_job_artifacts_empty() { .unwrap(); // Should get OK or INTERNAL_SERVER_ERROR - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR || response.status() == StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + || response.status() == StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -579,8 +655,7 @@ async fn test_api_artifacts_without_auth() { let temp_dir = tempfile::tempdir().unwrap(); let storage = FileStorage::new(temp_dir.path()).await.unwrap(); - let server = ApiServer::new("test-secret", pool) - .with_storage_extension(Arc::new(storage)); + let server = ApiServer::new("test-secret", pool).with_storage_extension(Arc::new(storage)); let app = server.into_router(); let response = app @@ -594,7 +669,10 @@ async fn test_api_artifacts_without_auth() { .unwrap(); // Should get UNAUTHORIZED or INTERNAL_SERVER_ERROR (if storage check fails first) - assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::UNAUTHORIZED + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -607,7 +685,9 @@ async fn test_api_pipelines_endpoint_list() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -627,7 +707,10 @@ async fn test_api_pipelines_endpoint_list() { .unwrap(); // Should get OK with empty array - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -640,7 +723,9 @@ async fn test_api_pipeline_runs_endpoint_list() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -660,7 +745,10 @@ async fn test_api_pipeline_runs_endpoint_list() { .unwrap(); // Should get OK with empty array - assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -673,7 +761,9 @@ async fn test_api_get_nonexistent_pipeline() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -693,7 +783,10 @@ async fn test_api_get_nonexistent_pipeline() { .unwrap(); // Should get NOT_FOUND or error - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -706,7 +799,9 @@ async fn test_api_get_nonexistent_pipeline_run() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -726,7 +821,10 @@ async fn test_api_get_nonexistent_pipeline_run() { .unwrap(); // Should get NOT_FOUND or error - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -739,7 +837,9 @@ async fn test_api_pipeline_invalid_id() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -759,7 +859,9 @@ async fn test_api_pipeline_invalid_id() { .unwrap(); // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies) - assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -772,7 +874,9 @@ async fn test_api_pipeline_run_invalid_id() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -792,7 +896,9 @@ async fn test_api_pipeline_run_invalid_id() { .unwrap(); // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies) - assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -805,7 +911,9 @@ async fn test_api_get_nonexistent_job() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -825,7 +933,10 @@ async fn test_api_get_nonexistent_job() { .unwrap(); // Should get NOT_FOUND or error - assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] @@ -838,7 +949,9 @@ async fn test_api_job_invalid_id() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -858,7 +971,9 @@ async fn test_api_job_invalid_id() { .unwrap(); // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies) - assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND); + assert!( + response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND + ); } #[tokio::test] @@ -871,7 +986,9 @@ async fn test_api_webhook_trigger_invalid_pipeline_id() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -906,7 +1023,9 @@ async fn test_api_webhook_trigger_not_found() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); @@ -970,7 +1089,9 @@ async fn test_api_webhook_trigger_success() { "test@example.com".to_string(), "hash".to_string(), ); - gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap(); + gitforce_db::queries::UserQueries::create(&pool, &user) + .await + .unwrap(); // Create repo first (pipeline has FK to repo) let repo_id = gitforce_common::RepoId::new(); @@ -983,7 +1104,9 @@ async fn test_api_webhook_trigger_success() { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; - gitforce_db::queries::RepoQueries::create(&pool, &repo).await.unwrap(); + gitforce_db::queries::RepoQueries::create(&pool, &repo) + .await + .unwrap(); // Create a pipeline first let pipeline = gitforce_db::models::Pipeline { @@ -994,7 +1117,9 @@ async fn test_api_webhook_trigger_success() { config: serde_json::json!({}), created_at: chrono::Utc::now(), }; - gitforce_db::queries::PipelineQueries::create(&pool, &pipeline).await.unwrap(); + gitforce_db::queries::PipelineQueries::create(&pool, &pipeline) + .await + .unwrap(); let server = ApiServer::new("test-secret", pool); let app = server.into_router(); diff --git a/crates/gitforce-ci/src/dag.rs b/crates/gitforce-ci/src/dag.rs index 2e2463ab..86c3783d 100644 --- a/crates/gitforce-ci/src/dag.rs +++ b/crates/gitforce-ci/src/dag.rs @@ -123,10 +123,7 @@ pub struct DagBuilder; impl DagBuilder { /// Build a job graph from a pipeline definition - pub fn build( - pipeline: &PipelineDefinition, - run_id: PipelineRunId, - ) -> Result { + pub fn build(pipeline: &PipelineDefinition, run_id: PipelineRunId) -> Result { let mut nodes = Vec::new(); let mut name_to_id = HashMap::new(); @@ -300,9 +297,18 @@ mod tests { assert_eq!(order.len(), 3); // build should come before test, test should come before deploy - let build_idx = order.iter().position(|id| graph.get(*id).unwrap().name == "build").unwrap(); - let test_idx = order.iter().position(|id| graph.get(*id).unwrap().name == "test").unwrap(); - let deploy_idx = order.iter().position(|id| graph.get(*id).unwrap().name == "deploy").unwrap(); + let build_idx = order + .iter() + .position(|id| graph.get(*id).unwrap().name == "build") + .unwrap(); + let test_idx = order + .iter() + .position(|id| graph.get(*id).unwrap().name == "test") + .unwrap(); + let deploy_idx = order + .iter() + .position(|id| graph.get(*id).unwrap().name == "deploy") + .unwrap(); assert!(build_idx < test_idx); assert!(test_idx < deploy_idx); @@ -405,10 +411,7 @@ mod tests { version: "1.0".to_string(), trigger_on: vec![], environment: HashMap::new(), - jobs: vec![ - make_job("build", vec![]), - make_job("test", vec!["build"]), - ], + jobs: vec![make_job("build", vec![]), make_job("test", vec!["build"])], }; let run_id = gitforce_common::PipelineRunId::new(); @@ -427,10 +430,7 @@ mod tests { version: "1.0".to_string(), trigger_on: vec![], environment: HashMap::new(), - jobs: vec![ - make_job("build", vec![]), - make_job("test", vec!["build"]), - ], + jobs: vec![make_job("build", vec![]), make_job("test", vec!["build"])], }; let run_id = gitforce_common::PipelineRunId::new(); diff --git a/crates/gitforce-ci/src/engine.rs b/crates/gitforce-ci/src/engine.rs index d2cec526..03e75399 100644 --- a/crates/gitforce-ci/src/engine.rs +++ b/crates/gitforce-ci/src/engine.rs @@ -44,7 +44,9 @@ impl CiEngineState { /// Check if all jobs succeeded pub fn all_jobs_succeeded(&self) -> bool { - self.jobs.values().all(|j| j.status() == JobStatus::Succeeded) + self.jobs + .values() + .all(|j| j.status() == JobStatus::Succeeded) } /// Get failed jobs @@ -144,14 +146,12 @@ impl CiEngine { } // Check if all dependencies are satisfied - let deps_satisfied = node - .dependencies - .iter() - .all(|dep_id| { - state.jobs.get(dep_id).is_some_and(|s| { - s.status() == JobStatus::Succeeded - }) - }); + let deps_satisfied = node.dependencies.iter().all(|dep_id| { + state + .jobs + .get(dep_id) + .is_some_and(|s| s.status() == JobStatus::Succeeded) + }); if deps_satisfied { ready.push(node.id); @@ -162,7 +162,11 @@ impl CiEngine { } /// Assign a job to a runner - pub async fn assign_job(&self, job_id: JobId, runner_id: gitforce_common::RunnerId) -> Result<()> { + pub async fn assign_job( + &self, + job_id: JobId, + runner_id: gitforce_common::RunnerId, + ) -> Result<()> { let mut state = self.state.write().await; if let Some(job_state) = state.jobs.get_mut(&job_id) { job_state.assign(runner_id)?; @@ -322,7 +326,14 @@ mod tests { // After build succeeds, test job is now ready (still needs to be picked up by scheduler) // Note: In real system, scheduler would dequeue it. For test, we verify pipeline completion. let state = engine.state().await; - assert!(!state.failed_jobs().is_empty() || state.status == PipelineStatus::Succeeded || state.jobs.values().any(|j| j.status() == JobStatus::Succeeded)); + assert!( + !state.failed_jobs().is_empty() + || state.status == PipelineStatus::Succeeded + || state + .jobs + .values() + .any(|j| j.status() == JobStatus::Succeeded) + ); } #[tokio::test] @@ -343,7 +354,10 @@ mod tests { engine.assign_job(build_job, runner_id).await.unwrap(); engine.start_job(build_job).await.unwrap(); - engine.fail_job(build_job, 1, "build failed".to_string()).await.unwrap(); + engine + .fail_job(build_job, 1, "build failed".to_string()) + .await + .unwrap(); let state = engine.state().await; assert_eq!(state.status, PipelineStatus::Failed); @@ -459,7 +473,10 @@ mod tests { engine.assign_job(build_job, runner_id).await.unwrap(); engine.start_job(build_job).await.unwrap(); - engine.fail_job(build_job, 1, "test failure".to_string()).await.unwrap(); + engine + .fail_job(build_job, 1, "test failure".to_string()) + .await + .unwrap(); let state = engine.state().await; assert!(!state.failed_jobs().is_empty()); diff --git a/crates/gitforce-ci/src/lib.rs b/crates/gitforce-ci/src/lib.rs index 43c477fa..1982c969 100644 --- a/crates/gitforce-ci/src/lib.rs +++ b/crates/gitforce-ci/src/lib.rs @@ -11,5 +11,7 @@ pub mod state; pub use dag::{DagBuilder, JobGraph, JobNode}; pub use engine::CiEngine; pub use executor::PipelineExecutor; -pub use pipeline::{PipelineDefinition, PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition}; +pub use pipeline::{ + JobDefinition, PipelineDefinition, PipelineTriggerEvent, StepDefinition, TriggerType, +}; pub use state::JobStateMachine; diff --git a/crates/gitforce-ci/src/pipeline.rs b/crates/gitforce-ci/src/pipeline.rs index 3fcb8946..363e1bc8 100644 --- a/crates/gitforce-ci/src/pipeline.rs +++ b/crates/gitforce-ci/src/pipeline.rs @@ -256,7 +256,8 @@ jobs: RepoId::new(), "abc123".to_string(), TriggerType::Push, - ).with_ref("refs/heads/main".to_string()); + ) + .with_ref("refs/heads/main".to_string()); assert_eq!(event.ref_name, Some("refs/heads/main".to_string())); } @@ -269,7 +270,8 @@ jobs: RepoId::new(), "abc123".to_string(), TriggerType::Push, - ).with_actor(user_id); + ) + .with_actor(user_id); assert_eq!(event.actor_id, Some(user_id)); } diff --git a/crates/gitforce-ci/src/state.rs b/crates/gitforce-ci/src/state.rs index 981d9e85..57e19f47 100644 --- a/crates/gitforce-ci/src/state.rs +++ b/crates/gitforce-ci/src/state.rs @@ -84,7 +84,12 @@ impl JobStateMachine { self.status = JobStatus::Failed; self.exit_code = Some(exit_code); self.error_message = Some(error); - tracing::error!("job {} failed with exit code {}: {}", self.job_id, exit_code, self.error_message.as_ref().unwrap()); + tracing::error!( + "job {} failed with exit code {}: {}", + self.job_id, + exit_code, + self.error_message.as_ref().unwrap() + ); Ok(()) } diff --git a/crates/gitforce-ci/tests/integration.rs b/crates/gitforce-ci/tests/integration.rs index 2c25cb00..64715b54 100644 --- a/crates/gitforce-ci/tests/integration.rs +++ b/crates/gitforce-ci/tests/integration.rs @@ -2,14 +2,15 @@ //! //! These tests require Docker and a running test database. -use gitforce_ci::{CiEngine, PipelineDefinition, PipelineTriggerEvent, TriggerType, DagBuilder}; -use gitforce_common::{PipelineId, RepoId, PipelineRunId}; +use gitforce_ci::{CiEngine, DagBuilder, PipelineDefinition, PipelineTriggerEvent, TriggerType}; +use gitforce_common::{PipelineId, PipelineRunId, RepoId}; use std::collections::HashMap; /// Create a test pipeline definition fn make_pipeline(jobs: Vec<(&str, Vec<&str>)>) -> PipelineDefinition { - let jobs = jobs.into_iter().map(|(name, needs)| { - gitforce_ci::JobDefinition { + let jobs = jobs + .into_iter() + .map(|(name, needs)| gitforce_ci::JobDefinition { name: name.to_string(), image: "rust:latest".to_string(), needs: needs.into_iter().map(|s| s.to_string()).collect(), @@ -17,8 +18,8 @@ fn make_pipeline(jobs: Vec<(&str, Vec<&str>)>) -> PipelineDefinition { steps: vec![], timeout: None, retry: None, - } - }).collect(); + }) + .collect(); PipelineDefinition { name: "test-pipeline".to_string(), @@ -52,7 +53,10 @@ async fn test_ci_engine_with_full_pipeline() { assert_eq!(graph.nodes.len(), 3); let engine = CiEngine::new(trigger, pipeline).await.unwrap(); - assert_eq!(engine.state().await.status, gitforce_common::PipelineStatus::Pending); + assert_eq!( + engine.state().await.status, + gitforce_common::PipelineStatus::Pending + ); } #[tokio::test] @@ -66,9 +70,7 @@ async fn test_ci_engine_single_job() { TriggerType::Push, ); - let pipeline = make_pipeline(vec![ - ("build", vec![]), - ]); + let pipeline = make_pipeline(vec![("build", vec![])]); let engine = CiEngine::new(trigger, pipeline).await.unwrap(); let state = engine.state().await; diff --git a/crates/gitforce-cli/src/client.rs b/crates/gitforce-cli/src/client.rs index b8fa82fe..1cba193d 100644 --- a/crates/gitforce-cli/src/client.rs +++ b/crates/gitforce-cli/src/client.rs @@ -90,7 +90,8 @@ mod tests { #[tokio::test] async fn test_api_client_get_success() { let mut mock_server = mockito::Server::new_async().await; - let m = mock_server.mock("GET", "/api/test") + let m = mock_server + .mock("GET", "/api/test") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"id":"123","name":"test"}"#) @@ -107,7 +108,8 @@ mod tests { #[tokio::test] async fn test_api_client_post_success() { let mut mock_server = mockito::Server::new_async().await; - let m = mock_server.mock("POST", "/api/test") + let m = mock_server + .mock("POST", "/api/test") .with_status(201) .with_header("content-type", "application/json") .with_body(r#"{"id":"456","name":"created"}"#) @@ -115,7 +117,9 @@ mod tests { let url = mock_server.url(); let client = ApiClient::new(&url, Some("token".to_string())); - let request = TestRequest { value: "test".to_string() }; + let request = TestRequest { + value: "test".to_string(), + }; let result: TestResponse = client.post("/api/test", &request).await.unwrap(); assert_eq!(result.id, "456"); m.assert(); @@ -124,7 +128,8 @@ mod tests { #[tokio::test] async fn test_api_client_delete_success() { let mut mock_server = mockito::Server::new_async().await; - let m = mock_server.mock("DELETE", "/api/test/123") + let m = mock_server + .mock("DELETE", "/api/test/123") .with_status(204) .create(); @@ -137,7 +142,8 @@ mod tests { #[tokio::test] async fn test_api_client_get_with_auth_header() { let mut mock_server = mockito::Server::new_async().await; - let m = mock_server.mock("GET", "/api/protected") + let m = mock_server + .mock("GET", "/api/protected") .match_header("Authorization", "Bearer my-secret-token") .with_status(200) .with_header("content-type", "application/json") @@ -154,7 +160,8 @@ mod tests { #[tokio::test] async fn test_api_client_get_without_token() { let mut mock_server = mockito::Server::new_async().await; - let m = mock_server.mock("GET", "/api/public") + let m = mock_server + .mock("GET", "/api/public") .match_header("Authorization", mockito::Matcher::Missing) .with_status(200) .with_header("content-type", "application/json") @@ -176,8 +183,8 @@ mod tests { #[test] fn test_api_client_with_token() { - let client = ApiClient::new("http://localhost:8080", None) - .with_token("test-token".to_string()); + let client = + ApiClient::new("http://localhost:8080", None).with_token("test-token".to_string()); // Token is set internally, verify client was created let client2 = ApiClient::new("http://localhost:8080", Some("test-token".to_string())); assert_eq!(client.token, client2.token); @@ -203,7 +210,8 @@ mod tests { #[test] fn test_api_client_with_token_method() { - let client = ApiClient::new("http://localhost:8080", None).with_token("my-token".to_string()); + let client = + ApiClient::new("http://localhost:8080", None).with_token("my-token".to_string()); assert_eq!(client.token, Some("my-token".to_string())); } diff --git a/crates/gitforce-cli/src/config.rs b/crates/gitforce-cli/src/config.rs index 73e9756c..0ffd0386 100644 --- a/crates/gitforce-cli/src/config.rs +++ b/crates/gitforce-cli/src/config.rs @@ -66,8 +66,8 @@ impl Config { /// Get config file path fn config_path() -> Result { - let base = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("could not find config directory"))?; + let base = + dirs::config_dir().ok_or_else(|| anyhow::anyhow!("could not find config directory"))?; Ok(base.join("gitforge").join("config.toml")) } } diff --git a/crates/gitforce-cli/src/main.rs b/crates/gitforce-cli/src/main.rs index 86d51bb6..a52e2d6e 100644 --- a/crates/gitforce-cli/src/main.rs +++ b/crates/gitforce-cli/src/main.rs @@ -2,17 +2,17 @@ //! //! Local-first Git platform client for GitForge. +use anyhow::Result; use clap::Parser; use clap::Subcommand; -use anyhow::Result; use std::path::PathBuf; -mod config; mod client; +mod config; mod sync; -pub use config::Config; pub use client::GitForgeClient; +pub use config::Config; #[derive(Parser, Debug)] #[command(name = "gitforge")] @@ -92,7 +92,11 @@ pub async fn run_cli(cli: Cli) -> Result<()> { let server = cli.server.unwrap_or_else(|| config.server_url.clone()); match &cli.command { - Commands::Auth { login, logout, status } => { + Commands::Auth { + login, + logout, + status, + } => { if let Some(username) = login { tracing::info!("Logging in as {} to {}", username, server); println!("Login successful! (API not yet implemented)"); @@ -106,7 +110,12 @@ pub async fn run_cli(cli: Cli) -> Result<()> { } } } - Commands::Repo { list, create, info, delete } => { + Commands::Repo { + list, + create, + info, + delete, + } => { if *list { println!("Repositories:\n (API not yet wired)"); } else if let Some(name) = create { @@ -119,7 +128,12 @@ pub async fn run_cli(cli: Cli) -> Result<()> { println!("Repository '{}' deleted (API not yet wired)", name); } } - Commands::Pipeline { list, show, run, watch } => { + Commands::Pipeline { + list, + show, + run, + watch, + } => { if *list { println!("Pipelines:\n (API not yet wired)"); } else if let Some(id) = show { @@ -130,17 +144,30 @@ pub async fn run_cli(cli: Cli) -> Result<()> { println!("Watching pipeline {} (not yet implemented)", id); } } - Commands::Runner { list, info, register, capacity } => { + Commands::Runner { + list, + info, + register, + capacity, + } => { if *list { println!("Runners:\n (API not yet wired)"); } else if let Some(id) = info { println!("Runner {} (API not yet wired)", id); } else if let Some(name) = register { let cap = capacity.unwrap_or(2); - println!("Runner '{}' registered with capacity {} (API not yet wired)", name, cap); + println!( + "Runner '{}' registered with capacity {} (API not yet wired)", + name, cap + ); } } - Commands::Sync { status, push, pull, init } => { + Commands::Sync { + status, + push, + pull, + init, + } => { let local_dir = config.local_data_dir.clone(); let sync_client = sync::SyncClient::with_real_client(local_dir.clone()); @@ -210,8 +237,11 @@ async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive(if cli.verbose { tracing::Level::DEBUG.into() } else { tracing::Level::INFO.into() }) + tracing_subscriber::EnvFilter::from_default_env().add_directive(if cli.verbose { + tracing::Level::DEBUG.into() + } else { + tracing::Level::INFO.into() + }), ) .init(); @@ -424,7 +454,11 @@ mod tests { #[tokio::test] async fn test_run_cli_sync_init_creates_directory() { let temp_dir = std::env::temp_dir(); - let test_dir = temp_dir.join("gitforge-test-init").to_str().unwrap().to_string(); + let test_dir = temp_dir + .join("gitforge-test-init") + .to_str() + .unwrap() + .to_string(); // Clean up if exists let _ = std::fs::remove_dir_all(&test_dir); @@ -448,7 +482,11 @@ mod tests { #[tokio::test] async fn test_run_cli_sync_init_fails_if_exists() { let temp_dir = std::env::temp_dir(); - let test_dir = temp_dir.join("gitforge-test-init-exists").to_str().unwrap().to_string(); + let test_dir = temp_dir + .join("gitforge-test-init-exists") + .to_str() + .unwrap() + .to_string(); // Create directory first std::fs::create_dir_all(&test_dir).unwrap(); @@ -490,4 +528,3 @@ mod tests { assert!(result.is_ok()); // Just prints message, doesn't fail } } - diff --git a/crates/gitforce-cli/src/sync.rs b/crates/gitforce-cli/src/sync.rs index 8567b18d..5aa6452c 100644 --- a/crates/gitforce-cli/src/sync.rs +++ b/crates/gitforce-cli/src/sync.rs @@ -325,12 +325,8 @@ impl SyncClient { /// Push local state to cloud pub async fn push(&self, api_url: &str, token: &str) -> Result { - let state = { - self.state.read().await.clone() - }; - let metadata = { - self.metadata.read().await.clone() - }; + let state = { self.state.read().await.clone() }; + let metadata = { self.metadata.read().await.clone() }; // Prepare push payload let payload = PushPayload { @@ -342,7 +338,8 @@ impl SyncClient { // POST to cloud sync endpoint let url = format!("{}/sync/push", api_url); - let push_response: PushResponse = self.http_client + let push_response: PushResponse = self + .http_client .post_json(&url, token, &payload) .await .context("failed to push to cloud")?; @@ -362,7 +359,8 @@ impl SyncClient { pub async fn pull(&self, api_url: &str, token: &str) -> Result { // GET from cloud sync endpoint let url = format!("{}/sync/pull", api_url); - let pull_response: PullResponse = self.http_client + let pull_response: PullResponse = self + .http_client .get_json(&url, token) .await .context("failed to pull from cloud")?; @@ -413,7 +411,13 @@ impl SyncClient { /// Add pipeline to local state #[allow(dead_code)] - pub async fn add_pipeline(&self, id: String, repo_id: String, name: String, definition: String) -> Result<()> { + pub async fn add_pipeline( + &self, + id: String, + repo_id: String, + name: String, + definition: String, + ) -> Result<()> { let mut state = self.state.write().await; state.pipelines.insert( id.clone(), @@ -493,7 +497,10 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let client = SyncClient::with_real_client(temp_dir.path().to_path_buf()); client.init().await.unwrap(); - client.add_repo("test-repo".to_string(), "repo-123".to_string()).await.unwrap(); + client + .add_repo("test-repo".to_string(), "repo-123".to_string()) + .await + .unwrap(); let state = client.state.read().await; assert!(state.repos.contains_key("test-repo")); } @@ -684,12 +691,15 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let client = SyncClient::with_real_client(temp_dir.path().to_path_buf()); client.init().await.unwrap(); - client.add_pipeline( - "pipe-1".to_string(), - "repo-1".to_string(), - "build".to_string(), - "steps: [build]".to_string(), - ).await.unwrap(); + client + .add_pipeline( + "pipe-1".to_string(), + "repo-1".to_string(), + "build".to_string(), + "steps: [build]".to_string(), + ) + .await + .unwrap(); let state = client.state.read().await; assert!(state.pipelines.contains_key("pipe-1")); } @@ -708,7 +718,10 @@ mod tests { let client = SyncClient::with_real_client(temp_dir.path().to_path_buf()); client.init().await.unwrap(); let before = client.state.read().await.updated_at.clone(); - client.add_repo("test-repo".to_string(), "repo-123".to_string()).await.unwrap(); + client + .add_repo("test-repo".to_string(), "repo-123".to_string()) + .await + .unwrap(); let after = client.state.read().await.updated_at.clone(); assert!(!after.is_empty()); } @@ -718,12 +731,15 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let client = SyncClient::with_real_client(temp_dir.path().to_path_buf()); client.init().await.unwrap(); - client.add_pipeline( - "pipe-1".to_string(), - "repo-1".to_string(), - "build".to_string(), - "steps: [build]".to_string(), - ).await.unwrap(); + client + .add_pipeline( + "pipe-1".to_string(), + "repo-1".to_string(), + "build".to_string(), + "steps: [build]".to_string(), + ) + .await + .unwrap(); let state = client.state.read().await; assert!(!state.updated_at.is_empty()); } @@ -769,13 +785,16 @@ mod tests { fn test_local_state_with_data() { let mut state = LocalState::default(); state.updated_at = "2024-06-15T12:00:00Z".to_string(); - state.repos.insert("test".to_string(), RepoState { - id: "id-1".to_string(), - name: "test".to_string(), - local_path: Some(PathBuf::from("/tmp/test")), - synced_at: None, - local_rev: 1, - }); + state.repos.insert( + "test".to_string(), + RepoState { + id: "id-1".to_string(), + name: "test".to_string(), + local_path: Some(PathBuf::from("/tmp/test")), + synced_at: None, + local_rev: 1, + }, + ); assert_eq!(state.repos.len(), 1); assert_eq!(state.updated_at, "2024-06-15T12:00:00Z"); } @@ -783,12 +802,11 @@ mod tests { #[tokio::test] async fn test_sync_client_push_with_mock() { let temp_dir = tempfile::tempdir().unwrap(); - let mock_client = MockHttpClient::new() - .with_push_response(PushResponse { - success: true, - remote_rev: 42, - conflicts: vec![], - }); + let mock_client = MockHttpClient::new().with_push_response(PushResponse { + success: true, + remote_rev: 42, + conflicts: vec![], + }); let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client); client.init().await.unwrap(); @@ -802,8 +820,7 @@ mod tests { #[tokio::test] async fn test_sync_client_push_with_mock_error() { let temp_dir = tempfile::tempdir().unwrap(); - let mock_client = MockHttpClient::new() - .with_push_error(anyhow::anyhow!("server error")); + let mock_client = MockHttpClient::new().with_push_error(anyhow::anyhow!("server error")); let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client); client.init().await.unwrap(); @@ -814,13 +831,12 @@ mod tests { #[tokio::test] async fn test_sync_client_pull_with_mock() { let temp_dir = tempfile::tempdir().unwrap(); - let mock_client = MockHttpClient::new() - .with_pull_response(PullResponse { - repos: HashMap::new(), - pipelines: HashMap::new(), - remote_rev: 100, - has_more: false, - }); + let mock_client = MockHttpClient::new().with_pull_response(PullResponse { + repos: HashMap::new(), + pipelines: HashMap::new(), + remote_rev: 100, + has_more: false, + }); let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client); client.init().await.unwrap(); @@ -834,8 +850,7 @@ mod tests { #[tokio::test] async fn test_sync_client_pull_with_mock_error() { let temp_dir = tempfile::tempdir().unwrap(); - let mock_client = MockHttpClient::new() - .with_push_error(anyhow::anyhow!("server error")); + let mock_client = MockHttpClient::new().with_push_error(anyhow::anyhow!("server error")); let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client); client.init().await.unwrap(); @@ -845,25 +860,23 @@ mod tests { #[test] fn test_mock_http_client_push_response() { - let mock = MockHttpClient::new() - .with_push_response(PushResponse { - success: true, - remote_rev: 5, - conflicts: vec!["a".to_string()], - }); + let mock = MockHttpClient::new().with_push_response(PushResponse { + success: true, + remote_rev: 5, + conflicts: vec!["a".to_string()], + }); assert!(mock.push_response.is_some()); assert!(mock.pull_response.is_none()); } #[test] fn test_mock_http_client_pull_response() { - let mock = MockHttpClient::new() - .with_pull_response(PullResponse { - repos: HashMap::new(), - pipelines: HashMap::new(), - remote_rev: 10, - has_more: true, - }); + let mock = MockHttpClient::new().with_pull_response(PullResponse { + repos: HashMap::new(), + pipelines: HashMap::new(), + remote_rev: 10, + has_more: true, + }); assert!(mock.pull_response.is_some()); assert!(mock.push_response.is_none()); } diff --git a/crates/gitforce-cli/tests/cli.rs b/crates/gitforce-cli/tests/cli.rs index b9ece248..7c90b3b9 100644 --- a/crates/gitforce-cli/tests/cli.rs +++ b/crates/gitforce-cli/tests/cli.rs @@ -77,10 +77,15 @@ enum TestCommands { #[test] fn test_cli_auth_login() { - let cli = TestCli::try_parse_from(&["gitforge", "--verbose", "auth", "--login", "testuser"]).unwrap(); + let cli = + TestCli::try_parse_from(&["gitforge", "--verbose", "auth", "--login", "testuser"]).unwrap(); assert!(cli.verbose); match cli.command { - TestCommands::Auth { login, logout: _, status: _ } => { + TestCommands::Auth { + login, + logout: _, + status: _, + } => { assert_eq!(login, Some("testuser".to_string())); } _ => panic!("Expected Auth command"), @@ -91,7 +96,11 @@ fn test_cli_auth_login() { fn test_cli_auth_logout() { let cli = TestCli::try_parse_from(&["gitforge", "auth", "--logout"]).unwrap(); match cli.command { - TestCommands::Auth { login: _, logout, status: _ } => { + TestCommands::Auth { + login: _, + logout, + status: _, + } => { assert!(logout); } _ => panic!("Expected Auth command"), @@ -102,7 +111,11 @@ fn test_cli_auth_logout() { fn test_cli_auth_status() { let cli = TestCli::try_parse_from(&["gitforge", "auth", "--status"]).unwrap(); match cli.command { - TestCommands::Auth { login: _, logout: _, status } => { + TestCommands::Auth { + login: _, + logout: _, + status, + } => { assert!(status); } _ => panic!("Expected Auth command"), @@ -113,7 +126,12 @@ fn test_cli_auth_status() { fn test_cli_repo_list() { let cli = TestCli::try_parse_from(&["gitforge", "repo", "--list"]).unwrap(); match cli.command { - TestCommands::Repo { list, create: _, info: _, delete: _ } => { + TestCommands::Repo { + list, + create: _, + info: _, + delete: _, + } => { assert!(list); } _ => panic!("Expected Repo command"), @@ -124,7 +142,12 @@ fn test_cli_repo_list() { fn test_cli_repo_create() { let cli = TestCli::try_parse_from(&["gitforge", "repo", "--create", "my-repo"]).unwrap(); match cli.command { - TestCommands::Repo { list: _, create, info: _, delete: _ } => { + TestCommands::Repo { + list: _, + create, + info: _, + delete: _, + } => { assert_eq!(create, Some("my-repo".to_string())); } _ => panic!("Expected Repo command"), @@ -135,7 +158,12 @@ fn test_cli_repo_create() { fn test_cli_repo_info() { let cli = TestCli::try_parse_from(&["gitforge", "repo", "--info", "test-repo"]).unwrap(); match cli.command { - TestCommands::Repo { list: _, create: _, info, delete: _ } => { + TestCommands::Repo { + list: _, + create: _, + info, + delete: _, + } => { assert_eq!(info, Some("test-repo".to_string())); } _ => panic!("Expected Repo command"), @@ -146,7 +174,12 @@ fn test_cli_repo_info() { fn test_cli_repo_delete() { let cli = TestCli::try_parse_from(&["gitforge", "repo", "--delete", "old-repo"]).unwrap(); match cli.command { - TestCommands::Repo { list: _, create: _, info: _, delete } => { + TestCommands::Repo { + list: _, + create: _, + info: _, + delete, + } => { assert_eq!(delete, Some("old-repo".to_string())); } _ => panic!("Expected Repo command"), @@ -157,7 +190,12 @@ fn test_cli_repo_delete() { fn test_cli_pipeline_list() { let cli = TestCli::try_parse_from(&["gitforge", "pipeline", "--list"]).unwrap(); match cli.command { - TestCommands::Pipeline { list, show: _, run: _, watch: _ } => { + TestCommands::Pipeline { + list, + show: _, + run: _, + watch: _, + } => { assert!(list); } _ => panic!("Expected Pipeline command"), @@ -168,7 +206,12 @@ fn test_cli_pipeline_list() { fn test_cli_pipeline_show() { let cli = TestCli::try_parse_from(&["gitforge", "pipeline", "--show", "abc123"]).unwrap(); match cli.command { - TestCommands::Pipeline { list: _, show, run: _, watch: _ } => { + TestCommands::Pipeline { + list: _, + show, + run: _, + watch: _, + } => { assert_eq!(show, Some("abc123".to_string())); } _ => panic!("Expected Pipeline command"), @@ -179,7 +222,12 @@ fn test_cli_pipeline_show() { fn test_cli_pipeline_run() { let cli = TestCli::try_parse_from(&["gitforge", "pipeline", "--run", "pipeline-xyz"]).unwrap(); match cli.command { - TestCommands::Pipeline { list: _, show: _, run, watch: _ } => { + TestCommands::Pipeline { + list: _, + show: _, + run, + watch: _, + } => { assert_eq!(run, Some("pipeline-xyz".to_string())); } _ => panic!("Expected Pipeline command"), @@ -190,7 +238,12 @@ fn test_cli_pipeline_run() { fn test_cli_runner_list() { let cli = TestCli::try_parse_from(&["gitforge", "runner", "--list"]).unwrap(); match cli.command { - TestCommands::Runner { list, info: _, register: _, capacity: _ } => { + TestCommands::Runner { + list, + info: _, + register: _, + capacity: _, + } => { assert!(list); } _ => panic!("Expected Runner command"), @@ -201,7 +254,12 @@ fn test_cli_runner_list() { fn test_cli_runner_register() { let cli = TestCli::try_parse_from(&["gitforge", "runner", "--register", "my-runner"]).unwrap(); match cli.command { - TestCommands::Runner { list: _, info: _, register, capacity } => { + TestCommands::Runner { + list: _, + info: _, + register, + capacity, + } => { assert_eq!(register, Some("my-runner".to_string())); assert_eq!(capacity, None); } @@ -211,9 +269,22 @@ fn test_cli_runner_register() { #[test] fn test_cli_runner_register_with_capacity() { - let cli = TestCli::try_parse_from(&["gitforge", "runner", "--register", "big-runner", "--capacity", "8"]).unwrap(); + let cli = TestCli::try_parse_from(&[ + "gitforge", + "runner", + "--register", + "big-runner", + "--capacity", + "8", + ]) + .unwrap(); match cli.command { - TestCommands::Runner { list: _, info: _, register, capacity } => { + TestCommands::Runner { + list: _, + info: _, + register, + capacity, + } => { assert_eq!(register, Some("big-runner".to_string())); assert_eq!(capacity, Some(8)); } @@ -225,7 +296,12 @@ fn test_cli_runner_register_with_capacity() { fn test_cli_sync_status() { let cli = TestCli::try_parse_from(&["gitforge", "sync", "--status"]).unwrap(); match cli.command { - TestCommands::Sync { status, push: _, pull: _, init: _ } => { + TestCommands::Sync { + status, + push: _, + pull: _, + init: _, + } => { assert!(status); } _ => panic!("Expected Sync command"), @@ -236,7 +312,12 @@ fn test_cli_sync_status() { fn test_cli_sync_init() { let cli = TestCli::try_parse_from(&["gitforge", "sync", "--init", "/path/to/dir"]).unwrap(); match cli.command { - TestCommands::Sync { status: _, push: _, pull: _, init } => { + TestCommands::Sync { + status: _, + push: _, + pull: _, + init, + } => { assert_eq!(init, Some("/path/to/dir".to_string())); } _ => panic!("Expected Sync command"), @@ -251,13 +332,21 @@ fn test_cli_verbose_flag() { #[test] fn test_cli_server_option() { - let cli = TestCli::try_parse_from(&["gitforge", "--server", "http://localhost:9090", "auth", "--status"]).unwrap(); + let cli = TestCli::try_parse_from(&[ + "gitforge", + "--server", + "http://localhost:9090", + "auth", + "--status", + ]) + .unwrap(); assert_eq!(cli.server, Some("http://localhost:9090".to_string())); } #[test] fn test_cli_token_option() { - let cli = TestCli::try_parse_from(&["gitforge", "--token", "secret-token", "auth", "--status"]).unwrap(); + let cli = TestCli::try_parse_from(&["gitforge", "--token", "secret-token", "auth", "--status"]) + .unwrap(); assert_eq!(cli.token, Some("secret-token".to_string())); } diff --git a/crates/gitforce-common/src/ids.rs b/crates/gitforce-common/src/ids.rs index 1075b091..21e89bd2 100644 --- a/crates/gitforce-common/src/ids.rs +++ b/crates/gitforce-common/src/ids.rs @@ -375,7 +375,7 @@ mod tests { let mut sorted = ids; sorted.sort(); for i in 1..sorted.len() { - assert_ne!(sorted[i], sorted[i-1], "IDs should be unique"); + assert_ne!(sorted[i], sorted[i - 1], "IDs should be unique"); } } diff --git a/crates/gitforce-common/src/lib.rs b/crates/gitforce-common/src/lib.rs index 4e004244..362326ca 100644 --- a/crates/gitforce-common/src/lib.rs +++ b/crates/gitforce-common/src/lib.rs @@ -9,9 +9,11 @@ pub mod error; pub mod ids; pub mod password; -pub mod time; pub mod result; +pub mod time; pub use error::{Error, ErrorKind, Result}; -pub use ids::{JobId, JobStatus, PipelineId, PipelineRunId, PipelineStatus, RepoId, RunnerId, StepId, UserId}; +pub use ids::{ + JobId, JobStatus, PipelineId, PipelineRunId, PipelineStatus, RepoId, RunnerId, StepId, UserId, +}; pub use time::DateTime; diff --git a/crates/gitforce-common/src/password.rs b/crates/gitforce-common/src/password.rs index f26c4095..b970a891 100644 --- a/crates/gitforce-common/src/password.rs +++ b/crates/gitforce-common/src/password.rs @@ -10,8 +10,7 @@ pub fn hash_password(password: &str) -> Result { /// Verify a password against a hash pub fn verify_password(password: &str, hash: &str) -> Result { - bcrypt::verify(password, hash) - .map_err(|e| PasswordError::VerifyError(e.to_string())) + bcrypt::verify(password, hash).map_err(|e| PasswordError::VerifyError(e.to_string())) } /// Password error types diff --git a/crates/gitforce-common/src/time.rs b/crates/gitforce-common/src/time.rs index 83b56544..7a371cd4 100644 --- a/crates/gitforce-common/src/time.rs +++ b/crates/gitforce-common/src/time.rs @@ -125,7 +125,10 @@ mod tests { #[test] fn test_is_older_than_edge_cases() { let now = Utc::now(); - assert!(is_older_than(now - chrono::Duration::seconds(1), chrono::Duration::seconds(0))); + assert!(is_older_than( + now - chrono::Duration::seconds(1), + chrono::Duration::seconds(0) + )); assert!(!is_older_than(now, chrono::Duration::days(1))); } } diff --git a/crates/gitforce-core/src/git_protocol/http.rs b/crates/gitforce-core/src/git_protocol/http.rs index ed2ee2f0..7ce96992 100644 --- a/crates/gitforce-core/src/git_protocol/http.rs +++ b/crates/gitforce-core/src/git_protocol/http.rs @@ -44,7 +44,10 @@ impl HttpGitHandler { // Get HEAD reference if let Ok(reference) = repo.head() { let ref_name = reference.name().unwrap_or("HEAD"); - let oid = reference.target().map(|oid| oid.to_string()).unwrap_or_default(); + let oid = reference + .target() + .map(|oid| oid.to_string()) + .unwrap_or_default(); if !oid.is_empty() { let line = format!("{}\0 capabilities^{{}}\n", ref_name); response.extend_from_slice(&Self::format_pkt_line(&format!("{} {}\n", oid, line))); @@ -77,11 +80,7 @@ impl HttpGitHandler { #[async_trait] impl GitProtocolHandler for HttpGitHandler { - async fn upload_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result> { + async fn upload_pack(&self, repo_id: RepoId, input: Vec) -> Result> { tracing::debug!( "upload_pack for repo {} ({} bytes input)", repo_id, @@ -103,11 +102,7 @@ impl GitProtocolHandler for HttpGitHandler { self.build_ref_advertisement(repo_id).await } - async fn receive_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result> { + async fn receive_pack(&self, repo_id: RepoId, input: Vec) -> Result> { tracing::debug!( "receive_pack for repo {} ({} bytes input)", repo_id, @@ -194,20 +189,19 @@ impl GitProtocolHandler for HttpGitHandler { /// Write pack data to repository's object database fn write_pack_to_odb(repo: &git2::Repository, pack_data: &[u8]) -> gitforce_common::Result<()> { // Write pack data to repository's odb - let odb = repo.odb().map_err(|e| { - gitforce_common::Error::git(format!("Failed to get odb: {}", e)) - })?; + let odb = repo + .odb() + .map_err(|e| gitforce_common::Error::git(format!("Failed to get odb: {}", e)))?; // Create a packwriter to write the pack - let mut packwriter = odb.packwriter().map_err(|e| { - gitforce_common::Error::git(format!("Failed to create packwriter: {}", e)) - })?; + let mut packwriter = odb + .packwriter() + .map_err(|e| gitforce_common::Error::git(format!("Failed to create packwriter: {}", e)))?; // Write the pack data // The packwriter implements Write trait - std::io::Write::write_all(&mut packwriter, pack_data).map_err(|e| { - gitforce_common::Error::git(format!("Failed to write pack: {}", e)) - })?; + std::io::Write::write_all(&mut packwriter, pack_data) + .map_err(|e| gitforce_common::Error::git(format!("Failed to write pack: {}", e)))?; // The pack is finalized when packwriter is dropped // (git2's OdbPackwriter::Drop implementation calls finalize) @@ -245,8 +239,14 @@ mod tests { #[test] fn test_parse_content_type() { - assert_eq!(parse_content_type("application/x-git-upload-pack-request"), Some("application/x-git-upload-pack-request")); - assert_eq!(parse_content_type("text/plain; charset=utf-8"), Some("text/plain")); + assert_eq!( + parse_content_type("application/x-git-upload-pack-request"), + Some("application/x-git-upload-pack-request") + ); + assert_eq!( + parse_content_type("text/plain; charset=utf-8"), + Some("text/plain") + ); } #[test] @@ -256,7 +256,10 @@ mod tests { // Just whitespace - trims to empty assert_eq!(parse_content_type(" "), Some("")); // Multiple semicolons - assert_eq!(parse_content_type("text/plain; charset=utf-8; boundary=abc"), Some("text/plain")); + assert_eq!( + parse_content_type("text/plain; charset=utf-8; boundary=abc"), + Some("text/plain") + ); } #[test] @@ -267,7 +270,10 @@ mod tests { ); assert_eq!( parse_service("git-receive-pack/owner/repo.git/info/refs"), - Some(("git-receive-pack".to_string(), "owner/repo.git/info/refs".to_string())) + Some(( + "git-receive-pack".to_string(), + "owner/repo.git/info/refs".to_string() + )) ); } @@ -281,7 +287,10 @@ mod tests { // Deep path assert_eq!( parse_service("/git-upload-pack/owner/repo/path/to/refs"), - Some(("git-upload-pack".to_string(), "owner/repo/path/to/refs".to_string())) + Some(( + "git-upload-pack".to_string(), + "owner/repo/path/to/refs".to_string() + )) ); // Single segment (should return None) assert_eq!(parse_service("git-upload-pack"), None); @@ -383,8 +392,14 @@ mod tests { #[test] fn test_parse_content_type_with_charset() { // More charset variations - assert_eq!(parse_content_type("application/json;charset=utf-8"), Some("application/json")); - assert_eq!(parse_content_type("text/html; charset=ISO-8859-1"), Some("text/html")); + assert_eq!( + parse_content_type("application/json;charset=utf-8"), + Some("application/json") + ); + assert_eq!( + parse_content_type("text/html; charset=ISO-8859-1"), + Some("text/html") + ); } #[test] @@ -392,7 +407,10 @@ mod tests { // Various Git URL formats assert_eq!( parse_service("/git-upload-pack/owner/project.git"), - Some(("git-upload-pack".to_string(), "owner/project.git".to_string())) + Some(( + "git-upload-pack".to_string(), + "owner/project.git".to_string() + )) ); assert_eq!( parse_service("git-receive-pack/my-org/my-repo"), diff --git a/crates/gitforce-core/src/git_protocol/mod.rs b/crates/gitforce-core/src/git_protocol/mod.rs index 081749b4..be6b122e 100644 --- a/crates/gitforce-core/src/git_protocol/mod.rs +++ b/crates/gitforce-core/src/git_protocol/mod.rs @@ -10,18 +10,10 @@ use std::future::Future; #[async_trait::async_trait] pub trait GitProtocolHandler: Send + Sync { /// Handle a git upload-pack request (git clone/fetch) - async fn upload_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result>; + async fn upload_pack(&self, repo_id: RepoId, input: Vec) -> Result>; /// Handle a git receive-pack request (git push) - async fn receive_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result>; + async fn receive_pack(&self, repo_id: RepoId, input: Vec) -> Result>; } /// Async function wrapper for GitProtocolHandler @@ -56,9 +48,7 @@ mod tests { #[tokio::test] async fn test_fn_handler_upload_pack() { - let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { - Ok(input) - }); + let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { Ok(input) }); let repo_id = RepoId::new(); let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await; @@ -68,9 +58,7 @@ mod tests { #[tokio::test] async fn test_fn_handler_receive_pack() { - let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { - Ok(input) - }); + let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { Ok(input) }); let repo_id = RepoId::new(); let result = handler.receive_pack(repo_id, vec![4, 5, 6]).await; @@ -80,9 +68,7 @@ mod tests { #[tokio::test] async fn test_fn_handler_empty_input() { - let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { - Ok(input) - }); + let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move { Ok(input) }); let repo_id = RepoId::new(); let result = handler.upload_pack(repo_id, vec![]).await; diff --git a/crates/gitforce-core/src/git_protocol/ssh.rs b/crates/gitforce-core/src/git_protocol/ssh.rs index ad8c5144..6288d7d6 100644 --- a/crates/gitforce-core/src/git_protocol/ssh.rs +++ b/crates/gitforce-core/src/git_protocol/ssh.rs @@ -32,11 +32,7 @@ impl SshGitHandler { #[async_trait] impl GitProtocolHandler for SshGitHandler { - async fn upload_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result> { + async fn upload_pack(&self, repo_id: RepoId, input: Vec) -> Result> { tracing::debug!( "ssh upload_pack for repo {} ({} bytes input)", repo_id, @@ -83,11 +79,7 @@ impl GitProtocolHandler for SshGitHandler { Ok(response) } - async fn receive_pack( - &self, - repo_id: RepoId, - input: Vec, - ) -> Result> { + async fn receive_pack(&self, repo_id: RepoId, input: Vec) -> Result> { tracing::debug!( "ssh receive_pack for repo {} ({} bytes input)", repo_id, @@ -174,19 +166,18 @@ impl GitProtocolHandler for SshGitHandler { /// Write pack data to repository's object database fn write_pack_to_odb(repo: &git2::Repository, pack_data: &[u8]) -> gitforce_common::Result<()> { // Write pack data to repository's odb - let odb = repo.odb().map_err(|e| { - gitforce_common::Error::git(format!("Failed to get odb: {}", e)) - })?; + let odb = repo + .odb() + .map_err(|e| gitforce_common::Error::git(format!("Failed to get odb: {}", e)))?; // Create a packwriter to write the pack - let mut packwriter = odb.packwriter().map_err(|e| { - gitforce_common::Error::git(format!("Failed to create packwriter: {}", e)) - })?; + let mut packwriter = odb + .packwriter() + .map_err(|e| gitforce_common::Error::git(format!("Failed to create packwriter: {}", e)))?; // Write the pack data - std::io::Write::write_all(&mut packwriter, pack_data).map_err(|e| { - gitforce_common::Error::git(format!("Failed to write pack: {}", e)) - })?; + std::io::Write::write_all(&mut packwriter, pack_data) + .map_err(|e| gitforce_common::Error::git(format!("Failed to write pack: {}", e)))?; // The pack is finalized when packwriter is dropped drop(packwriter); diff --git a/crates/gitforce-core/src/hooks.rs b/crates/gitforce-core/src/hooks.rs index 0028b3ac..d613ec6c 100644 --- a/crates/gitforce-core/src/hooks.rs +++ b/crates/gitforce-core/src/hooks.rs @@ -45,7 +45,12 @@ impl HookPayload { /// Get the branch name from the ref pub fn branch_name(&self) -> Option { if self.ref_name.starts_with("refs/heads/") { - Some(self.ref_name.strip_prefix("refs/heads/").unwrap().to_string()) + Some( + self.ref_name + .strip_prefix("refs/heads/") + .unwrap() + .to_string(), + ) } else { None } @@ -54,7 +59,12 @@ impl HookPayload { /// Get the tag name from the ref pub fn tag_name(&self) -> Option { if self.ref_name.starts_with("refs/tags/") { - Some(self.ref_name.strip_prefix("refs/tags/").unwrap().to_string()) + Some( + self.ref_name + .strip_prefix("refs/tags/") + .unwrap() + .to_string(), + ) } else { None } @@ -190,7 +200,11 @@ pub async fn execute_push_hooks( return Err(e); } - tracing::info!("Successfully executed hooks for push to {} on repo {}", ref_name, repo_id); + tracing::info!( + "Successfully executed hooks for push to {} on repo {}", + ref_name, + repo_id + ); Ok(()) } diff --git a/crates/gitforce-core/src/lib.rs b/crates/gitforce-core/src/lib.rs index 7b1755e3..86ce8947 100644 --- a/crates/gitforce-core/src/lib.rs +++ b/crates/gitforce-core/src/lib.rs @@ -7,6 +7,6 @@ pub mod hooks; pub mod repo; pub mod storage; -pub use hooks::{HookExecutor, HookManager, HookPayload, execute_push_hooks}; +pub use hooks::{execute_push_hooks, HookExecutor, HookManager, HookPayload}; pub use repo::{GitRef, RepoService}; pub use storage::{FileStorageBackend, StorageBackend}; diff --git a/crates/gitforce-core/src/repo.rs b/crates/gitforce-core/src/repo.rs index 033df1cc..55026aec 100644 --- a/crates/gitforce-core/src/repo.rs +++ b/crates/gitforce-core/src/repo.rs @@ -50,8 +50,12 @@ impl From for gitforce_common::Error { fn from(err: RepoError) -> Self { match err { RepoError::NotFound(id) => gitforce_common::Error::not_found("repository", id), - RepoError::AlreadyExists(name) => gitforce_common::Error::already_exists("repository", name), - RepoError::InvalidName(name) => gitforce_common::Error::invalid_input(format!("invalid repo name: {}", name)), + RepoError::AlreadyExists(name) => { + gitforce_common::Error::already_exists("repository", name) + } + RepoError::InvalidName(name) => { + gitforce_common::Error::invalid_input(format!("invalid repo name: {}", name)) + } RepoError::Storage(msg) => gitforce_common::Error::storage(msg), } } @@ -67,17 +71,16 @@ impl RepoService { } /// Create a new repository - pub async fn create( - &self, - name: String, - owner_id: UserId, - ) -> Result { + pub async fn create(&self, name: String, owner_id: UserId) -> Result { // Validate name if name.is_empty() || name.len() > 255 { return Err(RepoError::InvalidName(name).into()); } - if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') + { return Err(RepoError::InvalidName(name).into()); } @@ -163,7 +166,10 @@ impl RepoService { let mut refs = Vec::new(); // Iterate over all references - for name in repo.references().map_err(|e| Error::git(format!("failed to list refs: {}", e)))? { + for name in repo + .references() + .map_err(|e| Error::git(format!("failed to list refs: {}", e)))? + { let name = name.map_err(|e| Error::git(format!("failed to read ref: {}", e)))?; let ref_name = name.name().unwrap_or("").to_string(); let is_branch = ref_name.starts_with("refs/heads/"); @@ -200,7 +206,10 @@ mod tests { let owner_id = UserId::new(); // Create repository - let meta = service.create("test-repo".to_string(), owner_id).await.unwrap(); + let meta = service + .create("test-repo".to_string(), owner_id) + .await + .unwrap(); assert_eq!(meta.name, "test-repo"); // Get repository @@ -291,7 +300,10 @@ mod tests { let owner_id = UserId::new(); - let meta = service.create("test-repo".to_string(), owner_id).await.unwrap(); + let meta = service + .create("test-repo".to_string(), owner_id) + .await + .unwrap(); let path = service.get_git_path(meta.id).await.unwrap(); assert!(!path.is_empty()); @@ -343,7 +355,10 @@ mod tests { let service = RepoService::new(storage); let owner_id = UserId::new(); - let meta = service.create("test-repo".to_string(), owner_id).await.unwrap(); + let meta = service + .create("test-repo".to_string(), owner_id) + .await + .unwrap(); // Fresh repo has no refs let refs = service.list_refs(meta.id).await.unwrap(); @@ -385,7 +400,13 @@ mod tests { let owner_id = UserId::new(); // Test various invalid characters - for name in ["has!space", "has@at", "has#hash", "has$dollar", "has%percent"] { + for name in [ + "has!space", + "has@at", + "has#hash", + "has$dollar", + "has%percent", + ] { let result = service.create(name.to_string(), owner_id).await; assert!(result.is_err(), "Expected '{}' to be invalid", name); } diff --git a/crates/gitforce-core/src/storage.rs b/crates/gitforce-core/src/storage.rs index 73e3f84c..00af93f8 100644 --- a/crates/gitforce-core/src/storage.rs +++ b/crates/gitforce-core/src/storage.rs @@ -46,9 +46,9 @@ impl FileStorageBackend { /// Ensure the root directory exists pub async fn ensure_root(&self) -> Result<()> { - tokio::fs::create_dir_all(&self.root).await.map_err(|e| { - Error::storage(format!("failed to create storage root: {}", e)) - })?; + tokio::fs::create_dir_all(&self.root) + .await + .map_err(|e| Error::storage(format!("failed to create storage root: {}", e)))?; Ok(()) } } @@ -68,9 +68,9 @@ impl StorageBackend for FileStorageBackend { let path = self.repo_path(repo_id); // Create repository directory - tokio::fs::create_dir_all(&path).await.map_err(|e| { - Error::storage(format!("failed to create repo directory: {}", e)) - })?; + tokio::fs::create_dir_all(&path) + .await + .map_err(|e| Error::storage(format!("failed to create repo directory: {}", e)))?; // Initialize bare git repository self.init_bare(&path).await?; @@ -83,9 +83,9 @@ impl StorageBackend for FileStorageBackend { let path = self.repo_path(repo_id); if path.exists() { - tokio::fs::remove_dir_all(&path).await.map_err(|e| { - Error::storage(format!("failed to delete repository: {}", e)) - })?; + tokio::fs::remove_dir_all(&path) + .await + .map_err(|e| Error::storage(format!("failed to delete repository: {}", e)))?; tracing::info!("deleted repository at {:?}", path); } @@ -94,14 +94,13 @@ impl StorageBackend for FileStorageBackend { async fn init_bare(&self, path: &Path) -> Result<()> { // Use git2 to create a bare repository - let repo = git2::Repository::init_bare(path).map_err(|e| { - Error::git(format!("failed to initialize bare repository: {}", e)) - })?; + let repo = git2::Repository::init_bare(path) + .map_err(|e| Error::git(format!("failed to initialize bare repository: {}", e)))?; // Configure repository for optimal git server usage - let mut config = repo.config().map_err(|e| { - Error::git(format!("failed to get repository config: {}", e)) - })?; + let mut config = repo + .config() + .map_err(|e| Error::git(format!("failed to get repository config: {}", e)))?; // Disable garbage collection for server-side repos config.set_bool("gc.autodetach", false).ok(); @@ -114,9 +113,8 @@ impl StorageBackend for FileStorageBackend { async fn open(&self, repo_id: RepoId) -> Result { let path = self.repo_path(repo_id); - let repo = git2::Repository::open(&path).map_err(|e| { - Error::git(format!("failed to open repository at {:?}: {}", path, e)) - })?; + let repo = git2::Repository::open(&path) + .map_err(|e| Error::git(format!("failed to open repository: {}", e)))?; Ok(repo) } diff --git a/crates/gitforce-db/src/connection.rs b/crates/gitforce-db/src/connection.rs index 27f64070..e858ef11 100644 --- a/crates/gitforce-db/src/connection.rs +++ b/crates/gitforce-db/src/connection.rs @@ -14,14 +14,15 @@ impl Pool { /// Create a new connection pool from a database URL or file path pub async fn new(database_url: &str) -> Result { // Handle file paths - SQLite uses file: prefix or bare paths - let connect_url = if database_url.starts_with("sqlite:") || database_url.starts_with("file:") { - database_url.to_string() - } else if Path::new(database_url).exists() || database_url.contains('/') { - format!("sqlite:{}?mode=rwc", database_url) - } else { - // Memory database - "sqlite::memory:".to_string() - }; + let connect_url = + if database_url.starts_with("sqlite:") || database_url.starts_with("file:") { + database_url.to_string() + } else if Path::new(database_url).exists() || database_url.contains('/') { + format!("sqlite:{}?mode=rwc", database_url) + } else { + // Memory database + "sqlite::memory:".to_string() + }; let pool = SqlitePoolOptions::new() .max_connections(5) diff --git a/crates/gitforce-db/src/models/mod.rs b/crates/gitforce-db/src/models/mod.rs index 6065dec4..429ef656 100644 --- a/crates/gitforce-db/src/models/mod.rs +++ b/crates/gitforce-db/src/models/mod.rs @@ -1,17 +1,17 @@ //! Database models (simplified for MVP) -pub mod repo; -pub mod pipeline; +pub mod artifact; +pub mod event; pub mod job; +pub mod pipeline; +pub mod repo; pub mod runner; pub mod user; -pub mod event; -pub mod artifact; -pub use repo::*; -pub use pipeline::*; +pub use artifact::*; +pub use event::*; pub use job::*; +pub use pipeline::*; +pub use repo::*; pub use runner::*; pub use user::*; -pub use event::*; -pub use artifact::*; diff --git a/crates/gitforce-db/src/models/runner.rs b/crates/gitforce-db/src/models/runner.rs index 09b72720..e5728855 100644 --- a/crates/gitforce-db/src/models/runner.rs +++ b/crates/gitforce-db/src/models/runner.rs @@ -98,11 +98,7 @@ mod tests { #[test] fn test_runner_creation() { - let runner = Runner::new( - "test-runner".to_string(), - RunnerType::Docker, - 4, - ); + let runner = Runner::new("test-runner".to_string(), RunnerType::Docker, 4); assert_eq!(runner.name, "test-runner"); assert_eq!(runner.runner_type, "docker"); assert_eq!(runner.capacity, 4); @@ -111,33 +107,21 @@ mod tests { #[test] fn test_runner_heartbeat() { - let mut runner = Runner::new( - "test-runner".to_string(), - RunnerType::Docker, - 2, - ); + let mut runner = Runner::new("test-runner".to_string(), RunnerType::Docker, 2); runner.heartbeat(); assert!(runner.last_heartbeat.is_some()); } #[test] fn test_runner_set_busy() { - let mut runner = Runner::new( - "test-runner".to_string(), - RunnerType::Firecracker, - 2, - ); + let mut runner = Runner::new("test-runner".to_string(), RunnerType::Firecracker, 2); runner.set_busy(); assert_eq!(runner.status, "busy"); } #[test] fn test_runner_set_online() { - let mut runner = Runner::new( - "test-runner".to_string(), - RunnerType::BareMetal, - 1, - ); + let mut runner = Runner::new("test-runner".to_string(), RunnerType::BareMetal, 1); runner.set_busy(); runner.set_online(); assert_eq!(runner.status, "online"); @@ -145,11 +129,7 @@ mod tests { #[test] fn test_runner_is_healthy() { - let mut runner = Runner::new( - "test-runner".to_string(), - RunnerType::Docker, - 2, - ); + let mut runner = Runner::new("test-runner".to_string(), RunnerType::Docker, 2); runner.heartbeat(); assert!(runner.is_healthy(60)); assert!(!runner.is_healthy(0)); @@ -158,11 +138,7 @@ mod tests { #[test] fn test_runner_is_not_healthy_without_heartbeat() { // Create a runner and manually set last_heartbeat to None - let mut runner = Runner::new( - "test-runner".to_string(), - RunnerType::Docker, - 2, - ); + let mut runner = Runner::new("test-runner".to_string(), RunnerType::Docker, 2); runner.last_heartbeat = None; assert!(!runner.is_healthy(60)); } diff --git a/crates/gitforce-db/src/queries.rs b/crates/gitforce-db/src/queries.rs index e97da951..4d4eca3a 100644 --- a/crates/gitforce-db/src/queries.rs +++ b/crates/gitforce-db/src/queries.rs @@ -2,11 +2,11 @@ //! //! This module provides real SQLite query implementations for all database operations. -use gitforce_common::{Error, RepoId, Result, RunnerId, UserId, PipelineId, PipelineRunId, JobId}; +use crate::Pool; +use chrono::{DateTime, Utc}; +use gitforce_common::{Error, JobId, PipelineId, PipelineRunId, RepoId, Result, RunnerId, UserId}; use sqlx::Row; use uuid::Uuid; -use chrono::{DateTime, Utc}; -use crate::Pool; // ============================================================================ // Repository Queries @@ -63,7 +63,10 @@ impl RepoQueries { } /// List repositories by owner - pub async fn list_by_owner(pool: &Pool, owner_id: UserId) -> Result> { + pub async fn list_by_owner( + pool: &Pool, + owner_id: UserId, + ) -> Result> { let rows = sqlx::query("SELECT * FROM repositories WHERE owner_id = ?") .bind(owner_id.to_string()) .fetch_all(pool.pool()) @@ -177,7 +180,10 @@ impl UserQueries { } /// Get a user by username - pub async fn get_by_username(pool: &Pool, username: &str) -> Result> { + pub async fn get_by_username( + pool: &Pool, + username: &str, + ) -> Result> { let row = sqlx::query("SELECT * FROM users WHERE username = ?") .bind(username) .fetch_optional(pool.pool()) @@ -263,8 +269,7 @@ impl PipelineQueries { repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), name: row.get("name"), trigger_type: row.get("trigger_type"), - config: serde_json::from_str(&row.get::("config")) - .unwrap_or_default(), + config: serde_json::from_str(&row.get::("config")).unwrap_or_default(), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) .unwrap() .with_timezone(&Utc), @@ -274,12 +279,16 @@ impl PipelineQueries { } /// List pipelines by repository - pub async fn list_by_repo(pool: &Pool, repo_id: RepoId) -> Result> { - let rows = sqlx::query("SELECT * FROM pipelines WHERE repo_id = ? ORDER BY created_at DESC") - .bind(repo_id.to_string()) - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list pipelines: {}", e)))?; + pub async fn list_by_repo( + pool: &Pool, + repo_id: RepoId, + ) -> Result> { + let rows = + sqlx::query("SELECT * FROM pipelines WHERE repo_id = ? ORDER BY created_at DESC") + .bind(repo_id.to_string()) + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list pipelines: {}", e)))?; let pipelines = rows .into_iter() @@ -288,8 +297,7 @@ impl PipelineQueries { repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), name: row.get("name"), trigger_type: row.get("trigger_type"), - config: serde_json::from_str(&row.get::("config")) - .unwrap_or_default(), + config: serde_json::from_str(&row.get::("config")).unwrap_or_default(), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) .unwrap() .with_timezone(&Utc), @@ -313,8 +321,7 @@ impl PipelineQueries { repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), name: row.get("name"), trigger_type: row.get("trigger_type"), - config: serde_json::from_str(&row.get::("config")) - .unwrap_or_default(), + config: serde_json::from_str(&row.get::("config")).unwrap_or_default(), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) .unwrap() .with_timezone(&Utc), @@ -366,15 +373,19 @@ impl PipelineRunQueries { match row { Some(row) => Ok(Some(crate::models::PipelineRun { id: PipelineRunId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::("pipeline_id")).unwrap()), + pipeline_id: PipelineId::from( + Uuid::parse_str(&row.get::("pipeline_id")).unwrap(), + ), repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), status: row.get("status"), triggered_by: row.get("triggered_by"), commit_hash: row.get("commit_hash"), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -397,26 +408,35 @@ impl PipelineRunQueries { } /// List pipeline runs by pipeline - pub async fn list_by_pipeline(pool: &Pool, pipeline_id: PipelineId) -> Result> { - let rows = sqlx::query("SELECT * FROM pipeline_runs WHERE pipeline_id = ? ORDER BY created_at DESC") - .bind(pipeline_id.to_string()) - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list pipeline runs: {}", e)))?; + pub async fn list_by_pipeline( + pool: &Pool, + pipeline_id: PipelineId, + ) -> Result> { + let rows = sqlx::query( + "SELECT * FROM pipeline_runs WHERE pipeline_id = ? ORDER BY created_at DESC", + ) + .bind(pipeline_id.to_string()) + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list pipeline runs: {}", e)))?; let runs = rows .into_iter() .map(|row| crate::models::PipelineRun { id: PipelineRunId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::("pipeline_id")).unwrap()), + pipeline_id: PipelineId::from( + Uuid::parse_str(&row.get::("pipeline_id")).unwrap(), + ), repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), status: row.get("status"), triggered_by: row.get("triggered_by"), commit_hash: row.get("commit_hash"), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -439,15 +459,19 @@ impl PipelineRunQueries { .into_iter() .map(|row| crate::models::PipelineRun { id: PipelineRunId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::("pipeline_id")).unwrap()), + pipeline_id: PipelineId::from( + Uuid::parse_str(&row.get::("pipeline_id")).unwrap(), + ), repo_id: RepoId::from(Uuid::parse_str(&row.get::("repo_id")).unwrap()), status: row.get("status"), triggered_by: row.get("triggered_by"), commit_hash: row.get("commit_hash"), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -501,15 +525,20 @@ impl JobQueries { match row { Some(row) => Ok(Some(crate::models::Job { id: JobId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap()), + pipeline_run_id: PipelineRunId::from( + Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap(), + ), name: row.get("name"), status: row.get("status"), - runner_id: row.get::, _>("runner_id") + runner_id: row + .get::, _>("runner_id") .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), retry_count: row.get("retry_count"), @@ -544,26 +573,35 @@ impl JobQueries { } /// List jobs by pipeline run - pub async fn list_by_run(pool: &Pool, run_id: PipelineRunId) -> Result> { - let rows = sqlx::query("SELECT * FROM jobs WHERE pipeline_run_id = ? ORDER BY created_at ASC") - .bind(run_id.to_string()) - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list jobs: {}", e)))?; + pub async fn list_by_run( + pool: &Pool, + run_id: PipelineRunId, + ) -> Result> { + let rows = + sqlx::query("SELECT * FROM jobs WHERE pipeline_run_id = ? ORDER BY created_at ASC") + .bind(run_id.to_string()) + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list jobs: {}", e)))?; let jobs = rows .into_iter() .map(|row| crate::models::Job { id: JobId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap()), + pipeline_run_id: PipelineRunId::from( + Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap(), + ), name: row.get("name"), status: row.get("status"), - runner_id: row.get::, _>("runner_id") + runner_id: row + .get::, _>("runner_id") .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), retry_count: row.get("retry_count"), @@ -578,24 +616,30 @@ impl JobQueries { /// List all pending jobs pub async fn list_pending(pool: &Pool) -> Result> { - let rows = sqlx::query("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC") - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list pending jobs: {}", e)))?; + let rows = + sqlx::query("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC") + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list pending jobs: {}", e)))?; let jobs = rows .into_iter() .map(|row| crate::models::Job { id: JobId::from(Uuid::parse_str(&row.get::("id")).unwrap()), - pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap()), + pipeline_run_id: PipelineRunId::from( + Uuid::parse_str(&row.get::("pipeline_run_id")).unwrap(), + ), name: row.get("name"), status: row.get("status"), - runner_id: row.get::, _>("runner_id") + runner_id: row + .get::, _>("runner_id") .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)), - started_at: row.get::, _>("started_at") + started_at: row + .get::, _>("started_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), - finished_at: row.get::, _>("finished_at") + finished_at: row + .get::, _>("finished_at") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), retry_count: row.get("retry_count"), @@ -654,7 +698,8 @@ impl RunnerQueries { runner_type: row.get("runner_type"), status: row.get("status"), capacity: row.get("capacity"), - last_heartbeat: row.get::, _>("last_heartbeat") + last_heartbeat: row + .get::, _>("last_heartbeat") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -702,7 +747,8 @@ impl RunnerQueries { runner_type: row.get("runner_type"), status: row.get("status"), capacity: row.get("capacity"), - last_heartbeat: row.get::, _>("last_heartbeat") + last_heartbeat: row + .get::, _>("last_heartbeat") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -716,10 +762,11 @@ impl RunnerQueries { /// List online runners pub async fn list_online(pool: &Pool) -> Result> { - let rows = sqlx::query("SELECT * FROM runners WHERE status = 'online' ORDER BY created_at DESC") - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list online runners: {}", e)))?; + let rows = + sqlx::query("SELECT * FROM runners WHERE status = 'online' ORDER BY created_at DESC") + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list online runners: {}", e)))?; let runners = rows .into_iter() @@ -729,7 +776,8 @@ impl RunnerQueries { runner_type: row.get("runner_type"), status: row.get("status"), capacity: row.get("capacity"), - last_heartbeat: row.get::, _>("last_heartbeat") + last_heartbeat: row + .get::, _>("last_heartbeat") .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) @@ -768,9 +816,13 @@ impl EventQueries { } /// List events by type - pub async fn list_by_type(pool: &Pool, event_type: &str, limit: i64) -> Result> { + pub async fn list_by_type( + pool: &Pool, + event_type: &str, + limit: i64, + ) -> Result> { let rows = sqlx::query( - "SELECT * FROM events WHERE event_type = ? ORDER BY created_at DESC LIMIT ?" + "SELECT * FROM events WHERE event_type = ? ORDER BY created_at DESC LIMIT ?", ) .bind(event_type) .bind(limit) @@ -783,8 +835,7 @@ impl EventQueries { .map(|row| crate::models::Event { id: Uuid::parse_str(&row.get::("id")).unwrap(), event_type: row.get("event_type"), - payload: serde_json::from_str(&row.get::("payload")) - .unwrap_or_default(), + payload: serde_json::from_str(&row.get::("payload")).unwrap_or_default(), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) .unwrap() .with_timezone(&Utc), @@ -796,21 +847,18 @@ impl EventQueries { /// List recent events pub async fn list_recent(pool: &Pool, limit: i64) -> Result> { - let rows = sqlx::query( - "SELECT * FROM events ORDER BY created_at DESC LIMIT ?" - ) - .bind(limit) - .fetch_all(pool.pool()) - .await - .map_err(|e| Error::database(format!("failed to list events: {}", e)))?; + let rows = sqlx::query("SELECT * FROM events ORDER BY created_at DESC LIMIT ?") + .bind(limit) + .fetch_all(pool.pool()) + .await + .map_err(|e| Error::database(format!("failed to list events: {}", e)))?; let events = rows .into_iter() .map(|row| crate::models::Event { id: Uuid::parse_str(&row.get::("id")).unwrap(), event_type: row.get("event_type"), - payload: serde_json::from_str(&row.get::("payload")) - .unwrap_or_default(), + payload: serde_json::from_str(&row.get::("payload")).unwrap_or_default(), created_at: DateTime::parse_from_rfc3339(&row.get::("created_at")) .unwrap() .with_timezone(&Utc), @@ -890,12 +938,16 @@ mod tests { assert_eq!(found.unwrap().username, "testuser"); // Get by username - let found = UserQueries::get_by_username(&pool, "testuser").await.unwrap(); + let found = UserQueries::get_by_username(&pool, "testuser") + .await + .unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().email, "test@example.com"); // Not found - let found = UserQueries::get_by_username(&pool, "nonexistent").await.unwrap(); + let found = UserQueries::get_by_username(&pool, "nonexistent") + .await + .unwrap(); assert!(found.is_none()); // List all @@ -930,7 +982,9 @@ mod tests { RunnerQueries::heartbeat(&pool, runner.id).await.unwrap(); // Update status - RunnerQueries::update_status(&pool, runner.id, "offline").await.unwrap(); + RunnerQueries::update_status(&pool, runner.id, "offline") + .await + .unwrap(); let found = RunnerQueries::get(&pool, runner.id).await.unwrap(); assert_eq!(found.unwrap().status, "offline"); } @@ -1027,12 +1081,16 @@ mod tests { assert_eq!(found.unwrap().commit_hash, "abc123"); // Update status - PipelineRunQueries::update_status(&pool, run.id, "running").await.unwrap(); + PipelineRunQueries::update_status(&pool, run.id, "running") + .await + .unwrap(); let found = PipelineRunQueries::get(&pool, run.id).await.unwrap(); assert_eq!(found.unwrap().status, "running"); // List by pipeline - let runs = PipelineRunQueries::list_by_pipeline(&pool, pipeline.id).await.unwrap(); + let runs = PipelineRunQueries::list_by_pipeline(&pool, pipeline.id) + .await + .unwrap(); assert_eq!(runs.len(), 1); // List all @@ -1078,10 +1136,7 @@ mod tests { ); PipelineRunQueries::create(&pool, &run).await.unwrap(); - let job = crate::models::Job::new( - run.id, - "build".to_string(), - ); + let job = crate::models::Job::new(run.id, "build".to_string()); // Create JobQueries::create(&pool, &job).await.unwrap(); @@ -1092,7 +1147,9 @@ mod tests { assert_eq!(found.unwrap().name, "build"); // Update status - JobQueries::update_status(&pool, job.id, "running").await.unwrap(); + JobQueries::update_status(&pool, job.id, "running") + .await + .unwrap(); let found = JobQueries::get(&pool, job.id).await.unwrap(); assert_eq!(found.unwrap().status, "running"); @@ -1186,7 +1243,9 @@ mod tests { EventQueries::create(&pool, &event).await.unwrap(); // List non-existent type - let events = EventQueries::list_by_type(&pool, "nonexistent.type", 10).await.unwrap(); + let events = EventQueries::list_by_type(&pool, "nonexistent.type", 10) + .await + .unwrap(); assert!(events.is_empty()); } @@ -1215,7 +1274,9 @@ mod tests { pool.migrate().await.unwrap(); // List when no events - let events = EventQueries::list_by_type(&pool, "push.received", 10).await.unwrap(); + let events = EventQueries::list_by_type(&pool, "push.received", 10) + .await + .unwrap(); assert!(events.is_empty()); let recent = EventQueries::list_recent(&pool, 10).await.unwrap(); @@ -1236,7 +1297,9 @@ mod tests { let pool = Pool::memory().await.unwrap(); pool.migrate().await.unwrap(); - let found = PipelineQueries::get(&pool, PipelineId::new()).await.unwrap(); + let found = PipelineQueries::get(&pool, PipelineId::new()) + .await + .unwrap(); assert!(found.is_none()); } @@ -1245,7 +1308,9 @@ mod tests { let pool = Pool::memory().await.unwrap(); pool.migrate().await.unwrap(); - let found = PipelineRunQueries::get(&pool, PipelineRunId::new()).await.unwrap(); + let found = PipelineRunQueries::get(&pool, PipelineRunId::new()) + .await + .unwrap(); assert!(found.is_none()); } @@ -1281,7 +1346,9 @@ mod tests { EventQueries::create(&pool, &event).await.unwrap(); // List by type - let events = EventQueries::list_by_type(&pool, "push.received", 10).await.unwrap(); + let events = EventQueries::list_by_type(&pool, "push.received", 10) + .await + .unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, "push.received"); diff --git a/crates/gitforce-db/tests/integration.rs b/crates/gitforce-db/tests/integration.rs index 006b2d7b..01f51a0c 100644 --- a/crates/gitforce-db/tests/integration.rs +++ b/crates/gitforce-db/tests/integration.rs @@ -2,10 +2,15 @@ //! //! These tests use in-memory SQLite databases for testing. -use gitforce_db::Pool; -use gitforce_db::queries::{RepoQueries, UserQueries, RunnerQueries, PipelineQueries, PipelineRunQueries, JobQueries, EventQueries}; -use gitforce_db::models::{Repository, User, Runner, RunnerType, Pipeline, PipelineRun, Job, Event}; use gitforce_common::PipelineId; +use gitforce_db::models::{ + Event, Job, Pipeline, PipelineRun, Repository, Runner, RunnerType, User, +}; +use gitforce_db::queries::{ + EventQueries, JobQueries, PipelineQueries, PipelineRunQueries, RepoQueries, RunnerQueries, + UserQueries, +}; +use gitforce_db::Pool; #[tokio::test] async fn test_database_in_memory_pool() { @@ -13,7 +18,11 @@ async fn test_database_in_memory_pool() { pool.migrate().await.unwrap(); // Create user - let user = User::new("testuser".to_string(), "test@example.com".to_string(), "hash".to_string()); + let user = User::new( + "testuser".to_string(), + "test@example.com".to_string(), + "hash".to_string(), + ); UserQueries::create(&pool, &user).await.unwrap(); // Verify user can be retrieved @@ -28,11 +37,19 @@ async fn test_database_repository_crud() { pool.migrate().await.unwrap(); // Create user first - let user = User::new("owner".to_string(), "owner@example.com".to_string(), "hash".to_string()); + let user = User::new( + "owner".to_string(), + "owner@example.com".to_string(), + "hash".to_string(), + ); UserQueries::create(&pool, &user).await.unwrap(); // Create repository - let repo = Repository::new("test-repo".to_string(), user.id, "/git/test-repo".to_string()); + let repo = Repository::new( + "test-repo".to_string(), + user.id, + "/git/test-repo".to_string(), + ); RepoQueries::create(&pool, &repo).await.unwrap(); // List repositories @@ -60,7 +77,9 @@ async fn test_database_runner_operations() { assert_eq!(found.unwrap().name, "test-runner"); // Update status - RunnerQueries::update_status(&pool, runner.id, "offline").await.unwrap(); + RunnerQueries::update_status(&pool, runner.id, "offline") + .await + .unwrap(); let found = RunnerQueries::get(&pool, runner.id).await.unwrap(); assert_eq!(found.unwrap().status, "offline"); } @@ -71,10 +90,18 @@ async fn test_database_pipeline_with_dependencies() { pool.migrate().await.unwrap(); // Create user and repo - let user = User::new("owner".to_string(), "owner@example.com".to_string(), "hash".to_string()); + let user = User::new( + "owner".to_string(), + "owner@example.com".to_string(), + "hash".to_string(), + ); UserQueries::create(&pool, &user).await.unwrap(); - let repo = Repository::new("test-repo".to_string(), user.id, "/git/test-repo".to_string()); + let repo = Repository::new( + "test-repo".to_string(), + user.id, + "/git/test-repo".to_string(), + ); RepoQueries::create(&pool, &repo).await.unwrap(); // Create pipeline @@ -89,7 +116,12 @@ async fn test_database_pipeline_with_dependencies() { PipelineQueries::create(&pool, &pipeline).await.unwrap(); // Create pipeline run - let run = PipelineRun::new(pipeline.id, repo.id, "alice".to_string(), "abc123".to_string()); + let run = PipelineRun::new( + pipeline.id, + repo.id, + "alice".to_string(), + "abc123".to_string(), + ); PipelineRunQueries::create(&pool, &run).await.unwrap(); // Create job @@ -119,7 +151,9 @@ async fn test_database_event_storage() { EventQueries::create(&pool, &event).await.unwrap(); // List events by type - let events = EventQueries::list_by_type(&pool, "push.received", 10).await.unwrap(); + let events = EventQueries::list_by_type(&pool, "push.received", 10) + .await + .unwrap(); assert_eq!(events.len(), 1); // List recent events @@ -153,10 +187,18 @@ async fn test_database_job_state_transitions() { pool.migrate().await.unwrap(); // Setup: user, repo, pipeline, run, job - let user = User::new("owner".to_string(), "owner@example.com".to_string(), "hash".to_string()); + let user = User::new( + "owner".to_string(), + "owner@example.com".to_string(), + "hash".to_string(), + ); UserQueries::create(&pool, &user).await.unwrap(); - let repo = Repository::new("test-repo".to_string(), user.id, "/git/test-repo".to_string()); + let repo = Repository::new( + "test-repo".to_string(), + user.id, + "/git/test-repo".to_string(), + ); RepoQueries::create(&pool, &repo).await.unwrap(); let pipeline = Pipeline { @@ -169,7 +211,12 @@ async fn test_database_job_state_transitions() { }; PipelineQueries::create(&pool, &pipeline).await.unwrap(); - let run = PipelineRun::new(pipeline.id, repo.id, "alice".to_string(), "abc123".to_string()); + let run = PipelineRun::new( + pipeline.id, + repo.id, + "alice".to_string(), + "abc123".to_string(), + ); PipelineRunQueries::create(&pool, &run).await.unwrap(); let job = Job::new(run.id, "build".to_string()); @@ -180,7 +227,9 @@ async fn test_database_job_state_transitions() { RunnerQueries::create(&pool, &runner).await.unwrap(); // Update job status to running - JobQueries::update_status(&pool, job.id, "running").await.unwrap(); + JobQueries::update_status(&pool, job.id, "running") + .await + .unwrap(); let found = JobQueries::get(&pool, job.id).await.unwrap(); assert_eq!(found.unwrap().status, "running"); @@ -196,10 +245,18 @@ async fn test_database_pipeline_run_status_updates() { pool.migrate().await.unwrap(); // Setup - let user = User::new("owner".to_string(), "owner@example.com".to_string(), "hash".to_string()); + let user = User::new( + "owner".to_string(), + "owner@example.com".to_string(), + "hash".to_string(), + ); UserQueries::create(&pool, &user).await.unwrap(); - let repo = Repository::new("test-repo".to_string(), user.id, "/git/test-repo".to_string()); + let repo = Repository::new( + "test-repo".to_string(), + user.id, + "/git/test-repo".to_string(), + ); RepoQueries::create(&pool, &repo).await.unwrap(); let pipeline = Pipeline { @@ -212,12 +269,19 @@ async fn test_database_pipeline_run_status_updates() { }; PipelineQueries::create(&pool, &pipeline).await.unwrap(); - let run = PipelineRun::new(pipeline.id, repo.id, "alice".to_string(), "abc123".to_string()); + let run = PipelineRun::new( + pipeline.id, + repo.id, + "alice".to_string(), + "abc123".to_string(), + ); PipelineRunQueries::create(&pool, &run).await.unwrap(); // Update status through various states for status in &["pending", "running", "succeeded"] { - PipelineRunQueries::update_status(&pool, run.id, status).await.unwrap(); + PipelineRunQueries::update_status(&pool, run.id, status) + .await + .unwrap(); let found = PipelineRunQueries::get(&pool, run.id).await.unwrap(); assert_eq!(found.unwrap().status, *status); } diff --git a/crates/gitforce-delta/src/analyzer.rs b/crates/gitforce-delta/src/analyzer.rs index 968b3c80..87a07658 100644 --- a/crates/gitforce-delta/src/analyzer.rs +++ b/crates/gitforce-delta/src/analyzer.rs @@ -77,7 +77,10 @@ impl ExecutionScope { /// Whether to run security scans for this scope pub fn includes_security(&self) -> bool { - matches!(self, ExecutionScope::Standard | ExecutionScope::Full | ExecutionScope::Heavy) + matches!( + self, + ExecutionScope::Standard | ExecutionScope::Full | ExecutionScope::Heavy + ) } /// Whether to run E2E tests for this scope @@ -134,7 +137,12 @@ impl DeltaPlan { } if !self.affected_packages.is_empty() { - let pkgs: Vec<&str> = self.affected_packages.iter().map(|s| s.as_str()).take(5).collect(); + let pkgs: Vec<&str> = self + .affected_packages + .iter() + .map(|s| s.as_str()) + .take(5) + .collect(); let suffix = if self.affected_packages.len() > 5 { format!(" + {} more", self.affected_packages.len() - 5) } else { @@ -214,21 +222,13 @@ impl DeltaAnalyzer { /// Run git diff --stat and --numstat fn run_git_diff(&self) -> Result { let mut cmd = Command::new("git"); - cmd.args([ - "diff", - "--numstat", - "--raw", - "--no-color", - &self.base_ref, - ]); + cmd.args(["diff", "--numstat", "--raw", "--no-color", &self.base_ref]); if let Some(ref head) = self.head_ref { cmd.arg(head); } cmd.current_dir(&self.repo_root); - let output = cmd - .output() - .context("failed to run git diff")?; + let output = cmd.output().context("failed to run git diff")?; if !output.status.success() { anyhow::bail!( @@ -278,7 +278,7 @@ impl DeltaAnalyzer { } // Try raw format - if let Some(raw_part) = line.split('\t').last() { + if let Some(raw_part) = line.split('\t').next_back() { let path = raw_part.trim().to_string(); if path.is_empty() || path == "/dev/null" { return None; @@ -318,7 +318,6 @@ impl DeltaAnalyzer { } } } - // Python packages else if file.path.contains("/pyproject.toml") || file.path.contains("/setup.py") @@ -326,7 +325,6 @@ impl DeltaAnalyzer { { packages.insert("python".to_string()); } - // Rust crates else if file.path.contains("/Cargo.toml") || file.path.contains("/Cargo.lock") @@ -341,7 +339,6 @@ impl DeltaAnalyzer { } } } - // TypeScript / Node else if file.path.contains("/package.json") || file.path.contains("/tsconfig.json") @@ -351,12 +348,10 @@ impl DeltaAnalyzer { { packages.insert("typescript".to_string()); } - // Shell scripts else if file.path.ends_with(".sh") { packages.insert("shell".to_string()); } - // Docs else if file.path.starts_with("docs/") || file.path.ends_with(".md") @@ -364,7 +359,6 @@ impl DeltaAnalyzer { { packages.insert("docs".to_string()); } - // CI/CD config else if file.path.contains(".github/workflows/") || file.path.contains(".githooks/") @@ -457,8 +451,7 @@ impl DeltaAnalyzer { ]; let docs_only = files.iter().all(|f| { - docs_extensions.iter().any(|ext| f.path.ends_with(ext)) - || f.path.starts_with("docs/") + docs_extensions.iter().any(|ext| f.path.ends_with(ext)) || f.path.starts_with("docs/") }); let config_only = files.iter().all(|f| { @@ -483,11 +476,26 @@ mod tests { #[test] fn test_execution_scope_classification() { - assert_eq!(ExecutionScope::from_change_count(1, 5), ExecutionScope::Trivial); - assert_eq!(ExecutionScope::from_change_count(3, 80), ExecutionScope::Fast); - assert_eq!(ExecutionScope::from_change_count(15, 500), ExecutionScope::Standard); - assert_eq!(ExecutionScope::from_change_count(50, 2000), ExecutionScope::Heavy); - assert_eq!(ExecutionScope::from_change_count(200, 5000), ExecutionScope::Full); + assert_eq!( + ExecutionScope::from_change_count(1, 5), + ExecutionScope::Trivial + ); + assert_eq!( + ExecutionScope::from_change_count(3, 80), + ExecutionScope::Fast + ); + assert_eq!( + ExecutionScope::from_change_count(15, 500), + ExecutionScope::Standard + ); + assert_eq!( + ExecutionScope::from_change_count(50, 2000), + ExecutionScope::Heavy + ); + assert_eq!( + ExecutionScope::from_change_count(200, 5000), + ExecutionScope::Full + ); } #[test] diff --git a/crates/gitforce-delta/src/lib.rs b/crates/gitforce-delta/src/lib.rs index c720213f..76c41d0f 100644 --- a/crates/gitforce-delta/src/lib.rs +++ b/crates/gitforce-delta/src/lib.rs @@ -11,13 +11,18 @@ //! 3. **Dependency Graph** — Walk reverse dependencies (if X changed, Y may break) //! 4. **Scope Resolution** — Determine what to run: lint-only, test, build, etc. //! -//! # Example +//! # Example (not run) //! -//! ```rust -//! let analyzer = DeltaAnalyzer::new(".", "HEAD~1").await?; +//! ```no_run +//! # use gitforce_delta::DeltaAnalyzer; +//! # #[tokio::main] +//! # async fn main() -> Result<(), Box> { +//! let analyzer = DeltaAnalyzer::new(".", "HEAD~1"); //! let plan = analyzer.analyze().await?; //! println!("Affected packages: {:?}", plan.affected_packages); -//! println!("Run: {:?}", plan.execution_scope()); +//! println!("Run: {:?}", plan.execution_scope); +//! # Ok(()) +//! # } //! ``` pub mod analyzer; diff --git a/crates/gitforce-delta/src/mapper.rs b/crates/gitforce-delta/src/mapper.rs index 492a7e9c..31fe827f 100644 --- a/crates/gitforce-delta/src/mapper.rs +++ b/crates/gitforce-delta/src/mapper.rs @@ -98,21 +98,18 @@ impl PackageMapper { PackageMapping::new("api/**/*.go", "go:api", "go"), PackageMapping::new("services/**/*.go", "go:services", "go"), PackageMapping::lib("**/*.go", "go:.", "go"), - // ─── Python ────────────────────────────────────────────────────── PackageMapping::new("pyproject.toml", "python:module", "python"), PackageMapping::new("setup.py", "python:module", "python"), PackageMapping::new("src/**/*.py", "python:src", "python"), PackageMapping::new("tests/**/*.py", "python:tests", "python"), PackageMapping::lib("**/*.py", "python:.", "python"), - // ─── Rust ──────────────────────────────────────────────────────── PackageMapping::new("Cargo.toml", "rust:workspace", "rust"), PackageMapping::new("Cargo.lock", "rust:workspace", "rust"), PackageMapping::new("crates/*/Cargo.toml", "rust:crate", "rust"), PackageMapping::new("services/*/Cargo.toml", "rust:service", "rust"), PackageMapping::lib("**/*.rs", "rust:.", "rust"), - // ─── TypeScript / Node ────────────────────────────────────────── PackageMapping::new("package.json", "ts:module", "typescript"), PackageMapping::new("tsconfig.json", "ts:config", "typescript"), @@ -121,17 +118,14 @@ impl PackageMapper { PackageMapping::new("tests/**/*.ts", "ts:tests", "typescript"), PackageMapping::new("tests/**/*.tsx", "ts:tests", "typescript"), PackageMapping::lib("**/*.{ts,tsx,js,jsx}", "ts:.", "typescript"), - // ─── Shell ─────────────────────────────────────────────────────── PackageMapping::new("scripts/**/*.sh", "shell:scripts", "shell"), PackageMapping::new(".githooks/**/*.sh", "shell:hooks", "shell"), PackageMapping::lib("**/*.sh", "shell:.", "shell"), - // ─── Docs ─────────────────────────────────────────────────────── PackageMapping::new("docs/**/*.md", "docs", "markdown"), PackageMapping::new("README.md", "docs", "markdown"), PackageMapping::new("CHANGELOG.md", "docs", "markdown"), - // ─── CI/CD ─────────────────────────────────────────────────────── PackageMapping::new(".github/workflows/*.yml", "ci:workflows", "yaml"), PackageMapping::new(".github/workflows/*.yaml", "ci:workflows", "yaml"), @@ -140,7 +134,6 @@ impl PackageMapper { PackageMapping::new(".githooks/*/", "ci:hooks", "shell"), PackageMapping::new("Makefile", "ci:makefile", "shell"), PackageMapping::new("**/Dockerfile*", "ci:docker", "dockerfile"), - // ─── Config ────────────────────────────────────────────────────── PackageMapping::new("*.toml", "config", "toml"), PackageMapping::new("*.yaml", "config", "yaml"), @@ -155,9 +148,7 @@ impl PackageMapper { /// Map a single changed file path to its package pub fn map_file(&self, file_path: &str) -> Option<&PackageMapping> { - self.mappings - .iter() - .find(|m| m.pattern.matches(file_path)) + self.mappings.iter().find(|m| m.pattern.matches(file_path)) } /// Map multiple files and return unique affected packages diff --git a/crates/gitforce-events/src/bus.rs b/crates/gitforce-events/src/bus.rs index 9957d178..7ff492e9 100644 --- a/crates/gitforce-events/src/bus.rs +++ b/crates/gitforce-events/src/bus.rs @@ -113,10 +113,7 @@ impl EventBus for InMemoryEventBus { async fn subscribe(&self, filter: EventFilter) -> Result> { let rx = self.sender.subscribe(); - Ok(Box::new(InMemoryEventStream { - rx, - filter, - })) + Ok(Box::new(InMemoryEventStream { rx, filter })) } } @@ -171,7 +168,7 @@ impl EventStream for InMemoryEventStream { #[cfg(test)] mod tests { use super::*; - use crate::event::{PushReceivedPayload, EventType, EventPayload}; + use crate::event::{EventPayload, EventType, PushReceivedPayload}; #[tokio::test] async fn test_publish_subscribe() { @@ -203,9 +200,12 @@ mod tests { bus.publish(event.clone()).await.unwrap(); // Receive should get the event - let received = tokio::time::timeout(std::time::Duration::from_secs(1), futures::StreamExt::next(&mut stream)) - .await - .unwrap(); + let received = tokio::time::timeout( + std::time::Duration::from_secs(1), + futures::StreamExt::next(&mut stream), + ) + .await + .unwrap(); assert!(received.is_some()); } @@ -275,7 +275,7 @@ mod tests { new_hash: "def".to_string(), pusher_id: None, }), - Some(repo_id), // Pass repo_id to EventEnvelope::new + Some(repo_id), // Pass repo_id to EventEnvelope::new None, ); assert!(filter.matches(&event)); diff --git a/crates/gitforce-events/src/event.rs b/crates/gitforce-events/src/event.rs index b3ac78fc..3c4333f4 100644 --- a/crates/gitforce-events/src/event.rs +++ b/crates/gitforce-events/src/event.rs @@ -47,10 +47,7 @@ impl EventEnvelope { } /// Create a new event with a correlation ID - pub fn with_correlation( - mut self, - correlation_id: Uuid, - ) -> Self { + pub fn with_correlation(mut self, correlation_id: Uuid) -> Self { self.correlation_id = Some(correlation_id); self } @@ -525,7 +522,8 @@ mod tests { EventPayload::PushReceived(payload), None, None, - ).with_correlation(correlation_id); + ) + .with_correlation(correlation_id); assert_eq!(event.correlation_id, Some(correlation_id)); } @@ -554,8 +552,14 @@ mod tests { #[test] fn test_event_type_display() { assert_eq!(format!("{}", EventType::PushReceived), "push.received"); - assert_eq!(format!("{}", EventType::PipelineTriggered), "pipeline.triggered"); - assert_eq!(format!("{}", EventType::RunnerHeartbeat), "runner.heartbeat"); + assert_eq!( + format!("{}", EventType::PipelineTriggered), + "pipeline.triggered" + ); + assert_eq!( + format!("{}", EventType::RunnerHeartbeat), + "runner.heartbeat" + ); } #[test] diff --git a/crates/gitforce-events/src/lib.rs b/crates/gitforce-events/src/lib.rs index 3f78116e..264e6b51 100644 --- a/crates/gitforce-events/src/lib.rs +++ b/crates/gitforce-events/src/lib.rs @@ -8,8 +8,8 @@ pub mod serializer; pub mod types; pub mod webhook; -pub use bus::{EventBus, EventStream, InMemoryEventBus, EventFilter}; +pub use bus::{EventBus, EventFilter, EventStream, InMemoryEventBus}; pub use event::EventEnvelope; pub use serializer::EventSerializer; pub use types::*; -pub use webhook::{WebhookManager, WebhookPayload, WebhookEvent, WebhookSender, WebhookError}; +pub use webhook::{WebhookError, WebhookEvent, WebhookManager, WebhookPayload, WebhookSender}; diff --git a/crates/gitforce-events/src/serializer.rs b/crates/gitforce-events/src/serializer.rs index 6b8bb297..ada47a6e 100644 --- a/crates/gitforce-events/src/serializer.rs +++ b/crates/gitforce-events/src/serializer.rs @@ -60,7 +60,7 @@ impl JsonEvent { #[cfg(test)] mod tests { use super::*; - use crate::event::{PushReceivedPayload, EventType, EventPayload, PipelineStartedPayload}; + use crate::event::{EventPayload, EventType, PipelineStartedPayload, PushReceivedPayload}; #[test] fn test_serialize_roundtrip() { @@ -160,7 +160,8 @@ mod tests { }), None, None, - ).with_correlation(correlation_id); + ) + .with_correlation(correlation_id); let json_event = JsonEvent::from_envelope(&event); assert!(json_event.correlation_id.is_some()); @@ -217,7 +218,8 @@ mod tests { #[test] fn test_serializer_deserialize_from_str_invalid() { - let result: Result = EventSerializer::deserialize_from_str("invalid json"); + let result: Result = + EventSerializer::deserialize_from_str("invalid json"); assert!(result.is_err()); } diff --git a/crates/gitforce-events/src/types.rs b/crates/gitforce-events/src/types.rs index 71cb423a..fd294647 100644 --- a/crates/gitforce-events/src/types.rs +++ b/crates/gitforce-events/src/types.rs @@ -3,12 +3,9 @@ //! Re-exports all event types from the event module. pub use crate::event::{ - EventEnvelope, EventPayload, EventType, - RepoCreatedPayload, RepoDeletedPayload, - PushReceivedPayload, RefUpdatedPayload, - PipelineTriggeredPayload, PipelineStartedPayload, PipelineFinishedPayload, - JobQueuedPayload, JobStartedPayload, JobFinishedPayload, - ArtifactCreatedPayload, - RunnerRegisteredPayload, RunnerHeartbeatPayload, RunnerOfflinePayload, - MirrorSyncRequestedPayload, MirrorSyncCompletedPayload, + ArtifactCreatedPayload, EventEnvelope, EventPayload, EventType, JobFinishedPayload, + JobQueuedPayload, JobStartedPayload, MirrorSyncCompletedPayload, MirrorSyncRequestedPayload, + PipelineFinishedPayload, PipelineStartedPayload, PipelineTriggeredPayload, PushReceivedPayload, + RefUpdatedPayload, RepoCreatedPayload, RepoDeletedPayload, RunnerHeartbeatPayload, + RunnerOfflinePayload, RunnerRegisteredPayload, }; diff --git a/crates/gitforce-events/src/webhook.rs b/crates/gitforce-events/src/webhook.rs index 3176a11c..e3938ee9 100644 --- a/crates/gitforce-events/src/webhook.rs +++ b/crates/gitforce-events/src/webhook.rs @@ -110,7 +110,7 @@ impl HttpWebhookSender { /// Generate signature for payload fn generate_signature(&self, payload: &[u8]) -> Option { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let secret = self.secret.as_ref()?; let mut hasher = Sha256::new(); @@ -124,8 +124,8 @@ impl HttpWebhookSender { #[async_trait] impl WebhookSender for HttpWebhookSender { async fn send(&self, payload: &WebhookPayload) -> Result<(), WebhookError> { - let json = serde_json::to_vec(payload) - .map_err(|e| WebhookError::Serialization(e.to_string()))?; + let json = + serde_json::to_vec(payload).map_err(|e| WebhookError::Serialization(e.to_string()))?; let mut request = self.client.post(&self.url); @@ -136,7 +136,10 @@ impl WebhookSender for HttpWebhookSender { request .header("Content-Type", "application/json") - .header("X-GitForge-Event", serde_json::to_string(&payload.event).unwrap_or_default()) + .header( + "X-GitForge-Event", + serde_json::to_string(&payload.event).unwrap_or_default(), + ) .body(json) .send() .await @@ -396,10 +399,7 @@ mod tests { #[test] fn test_webhook_payload_new_with_empty_data() { - let payload = WebhookPayload::new( - WebhookEvent::RunnerRegistered, - serde_json::json!({}), - ); + let payload = WebhookPayload::new(WebhookEvent::RunnerRegistered, serde_json::json!({})); assert_eq!(payload.event, WebhookEvent::RunnerRegistered); assert!(payload.data.is_object()); diff --git a/crates/gitforce-mcp/src/handlers.rs b/crates/gitforce-mcp/src/handlers.rs index 5e3a927c..f34d6793 100644 --- a/crates/gitforce-mcp/src/handlers.rs +++ b/crates/gitforce-mcp/src/handlers.rs @@ -15,15 +15,24 @@ pub struct ToolCall { impl ToolCall { fn arg_bool(&self, name: &str) -> bool { - self.arguments.get(name).and_then(|v| v.as_bool()).unwrap_or(false) + self.arguments + .get(name) + .and_then(|v| v.as_bool()) + .unwrap_or(false) } fn arg_str(&self, name: &str) -> Option { - self.arguments.get(name).and_then(|v| v.as_str()).map(String::from) + self.arguments + .get(name) + .and_then(|v| v.as_str()) + .map(String::from) } fn arg_usize(&self, name: &str) -> Option { - self.arguments.get(name).and_then(|v| v.as_u64()).map(|v| v as usize) + self.arguments + .get(name) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) } } @@ -50,17 +59,12 @@ impl McPHandler { // ─── CI Run ──────────────────────────────────────────────────────────── fn ci_run(&self, call: ToolCall) -> ToolResult { - let repo = call - .arg_str("repo") - .unwrap_or_else(|| Self::infer_repo()); - let branch = call - .arg_str("branch") - .unwrap_or_else(|| Self::current_branch()); + let repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); + let branch = call.arg_str("branch").unwrap_or_else(Self::current_branch); let delta = call.arg_bool("delta"); let scope_override = call.arg_str("scope"); let workflows = call.arg_str("workflows").unwrap_or_default(); - let rt = Runtime::new().expect("failed to create tokio runtime"); let scope_str = if delta { let delta_result = self.delta_plan(call.clone()); scope_override @@ -94,19 +98,14 @@ impl McPHandler { } fn ci_status(&self, call: ToolCall) -> ToolResult { - let repo = call - .arg_str("repo") - .unwrap_or_else(|| Self::infer_repo()); + let repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); let run_id = call.arg_str("run_id"); let branch = call.arg_str("branch"); let status = if let Some(id) = run_id { Self::gh_run_status(&repo, &id) } else { - Self::gh_latest_run_status( - &repo, - branch.as_deref().unwrap_or("HEAD"), - ) + Self::gh_latest_run_status(&repo, branch.as_deref().unwrap_or("HEAD")) }; match status { @@ -116,9 +115,7 @@ impl McPHandler { } fn ci_cancel(&self, call: ToolCall) -> ToolResult { - let repo = call - .arg_str("repo") - .unwrap_or_else(|| Self::infer_repo()); + let repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); let run_id = match call.arg_str("run_id") { Some(id) => id, None => return ToolResult::err("run_id is required"), @@ -143,15 +140,13 @@ impl McPHandler { // ─── Delta Plan ─────────────────────────────────────────────────────── fn delta_plan(&self, call: ToolCall) -> ToolResult { - let repo_root = call - .arg_str("repo_root") - .unwrap_or_else(|| ".".to_string()); + let repo_root = call.arg_str("repo_root").unwrap_or_else(|| ".".to_string()); let base_ref = call .arg_str("base_ref") .unwrap_or_else(|| "HEAD~1".to_string()); + let rt = Runtime::new().expect("failed to create tokio runtime"); let analyzer = DeltaAnalyzer::new(&repo_root, &base_ref); - let rt = Runtime::new().expect("failed to create tokio runtime"); let plan = match rt.block_on(analyzer.analyze()) { Ok(p) => p, Err(e) => return ToolResult::err(format!("Delta analysis failed: {}", e)), @@ -274,7 +269,7 @@ impl McPHandler { } fn get_repo_config(&self, call: ToolCall) -> ToolResult { - let _repo = call.arg_str("repo").unwrap_or_else(|| Self::infer_repo()); + let _repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); let config = RepoConfig { default_branch: "main".to_string(), @@ -298,25 +293,19 @@ impl McPHandler { } fn pr_create(&self, call: ToolCall) -> ToolResult { - let repo = call - .arg_str("repo") - .unwrap_or_else(|| Self::infer_repo()); + let repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); let title = match call.arg_str("title") { Some(t) => t, None => return ToolResult::err("title is required"), }; let body = call.arg_str("body").unwrap_or_default(); - let head = call.arg_str("head").unwrap_or_else(|| Self::current_branch()); + let head = call.arg_str("head").unwrap_or_else(Self::current_branch); let base = call.arg_str("base").unwrap_or_else(|| "main".to_string()); let output = Command::new("gh") .args([ - "pr", "create", - "--repo", &repo, - "--title", &title, - "--body", &body, - "--head", &head, - "--base", &base, + "pr", "create", "--repo", &repo, "--title", &title, "--body", &body, "--head", + &head, "--base", &base, ]) .output(); @@ -337,21 +326,24 @@ impl McPHandler { } fn pr_merge(&self, call: ToolCall) -> ToolResult { - let repo = call - .arg_str("repo") - .unwrap_or_else(|| Self::infer_repo()); + let repo = call.arg_str("repo").unwrap_or_else(Self::infer_repo); let pr_number: u64 = match call.arg_usize("pr_number") { Some(n) => n as u64, None => return ToolResult::err("pr_number is required"), }; - let method = call.arg_str("method").unwrap_or_else(|| "squash".to_string()); + let method = call + .arg_str("method") + .unwrap_or_else(|| "squash".to_string()); let output = Command::new("gh") .args([ - "pr", "merge", - "--repo", &repo, + "pr", + "merge", + "--repo", + &repo, &pr_number.to_string(), - "--admin", "--squash", + "--admin", + "--squash", ]) .output(); @@ -398,7 +390,12 @@ impl McPHandler { fn gh_run_status(repo: &str, run_id: &str) -> Result { let output = Command::new("gh") .args([ - "run", "view", run_id, "--repo", repo, "--json", + "run", + "view", + run_id, + "--repo", + repo, + "--json", "id,status,conclusion,headBranch,headSha,runNumber,createdAt,url", ]) .output() @@ -420,7 +417,10 @@ impl McPHandler { branch: json["headBranch"].as_str().unwrap_or("").to_string(), commit: json["headSha"].as_str().unwrap_or("").to_string(), status: json["status"].as_str().unwrap_or("").to_string(), - conclusion: json.get("conclusion").and_then(|v| v.as_str()).map(String::from), + conclusion: json + .get("conclusion") + .and_then(|v| v.as_str()) + .map(String::from), jobs: vec![], triggered_by: "cli".to_string(), run_at: json["createdAt"].as_str().unwrap_or("").to_string(), @@ -431,9 +431,16 @@ impl McPHandler { fn gh_latest_run_status(repo: &str, branch: &str) -> Result { let output = Command::new("gh") .args([ - "run", "list", "--repo", repo, "--branch", branch, - "--json", "id,status,conclusion,headBranch,headSha,runNumber,createdAt,url", - "--limit", "1", + "run", + "list", + "--repo", + repo, + "--branch", + branch, + "--json", + "id,status,conclusion,headBranch,headSha,runNumber,createdAt,url", + "--limit", + "1", ]) .output() .map_err(|e| format!("gh error: {}", e))?; @@ -456,7 +463,10 @@ impl McPHandler { branch: run["headBranch"].as_str().unwrap_or("").to_string(), commit: run["headSha"].as_str().unwrap_or("").to_string(), status: run["status"].as_str().unwrap_or("").to_string(), - conclusion: run.get("conclusion").and_then(|v| v.as_str()).map(String::from), + conclusion: run + .get("conclusion") + .and_then(|v| v.as_str()) + .map(String::from), jobs: vec![], triggered_by: "cli".to_string(), run_at: run["createdAt"].as_str().unwrap_or("").to_string(), diff --git a/crates/gitforce-mcp/src/http_server.rs b/crates/gitforce-mcp/src/http_server.rs index 21281279..6c0611a1 100644 --- a/crates/gitforce-mcp/src/http_server.rs +++ b/crates/gitforce-mcp/src/http_server.rs @@ -5,16 +5,8 @@ //! GET / — Server-Sent Events stream for server-initiated messages use anyhow::Result; -use axum::{ - extract::State, - http::StatusCode, - response::Json, - routing::post, - Router, -}; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; -use tokio::sync::broadcast; +use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router}; +use std::sync::Arc; use tracing::info; use crate::handlers::{McPHandler, ToolCall}; @@ -28,7 +20,9 @@ struct HttpState { impl HttpState { fn new() -> Self { - Self { handler: McPHandler } + Self { + handler: McPHandler, + } } fn handle_rpc(&self, req: JsonRpcRequest) -> Result { @@ -45,11 +39,21 @@ impl HttpState { }), "tools/call" => { - let tool_name = req.params.get("name") - .and_then(|v| v.as_str()).unwrap_or(""); - let arguments = req.params.get("arguments") - .and_then(|v| v.as_object()).cloned().unwrap_or_default(); - let call = ToolCall { name: tool_name.to_string(), arguments }; + let tool_name = req + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = req + .params + .get("arguments") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let call = ToolCall { + name: tool_name.to_string(), + arguments, + }; let result = self.handler.handle(call); return Ok(serde_json::json!({ "jsonrpc": "2.0", @@ -198,9 +202,7 @@ async fn handle_rpc( /// Start the HTTP MCP server pub async fn run_http(port: u16) -> Result<()> { let state = Arc::new(HttpState::new()); - let app = Router::new() - .route("/", post(handle_rpc)) - .with_state(state); + let app = Router::new().route("/", post(handle_rpc)).with_state(state); let addr = format!("0.0.0.0:{}", port); info!("Starting HTTP MCP server on http://{}", addr); diff --git a/crates/gitforce-mcp/src/main.rs b/crates/gitforce-mcp/src/main.rs index 8962cd2c..48f0dfcc 100644 --- a/crates/gitforce-mcp/src/main.rs +++ b/crates/gitforce-mcp/src/main.rs @@ -34,8 +34,7 @@ async fn main() { // Initialize logging to stderr (stdout is reserved for JSON-RPC in stdio mode) tracing_subscriber::fmt() .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info")), + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .with_target(false) .with_writer(std::io::stderr) diff --git a/crates/gitforce-mcp/src/server.rs b/crates/gitforce-mcp/src/server.rs index 9daba00f..50a1b3dd 100644 --- a/crates/gitforce-mcp/src/server.rs +++ b/crates/gitforce-mcp/src/server.rs @@ -72,56 +72,69 @@ impl McpServer { }), ), - "tools/list" => JsonRpcResponse::ok(req.id.clone(), serde_json::json!({ - "tools": [ - {"name": "ci_run", "description": "Trigger a CI run with optional delta analysis", - "inputSchema": {"type": "object", "properties": { - "repo": {"type": "string"}, "branch": {"type": "string"}, - "delta": {"type": "boolean", "default": true}, - "scope": {"type": "string", "enum": ["trivial","fast","standard","full","heavy"]}, - "workflows": {"type": "string"} - }}}, - {"name": "ci_status", "description": "Get CI run status", - "inputSchema": {"type": "object", "properties": { - "repo": {"type": "string"}, "run_id": {"type": "string"}, "branch": {"type": "string"} - }}}, - {"name": "ci_cancel", "description": "Cancel a running CI job", - "inputSchema": {"type": "object", "properties": { - "repo": {"type": "string"}, "run_id": {"type": "string", "description": "Run ID to cancel"} - }, "required": ["run_id"]}}, - {"name": "delta_plan", "description": "Analyze git diff and preview what CI would run", - "inputSchema": {"type": "object", "properties": { - "repo_root": {"type": "string", "default": "."}, - "base_ref": {"type": "string", "default": "HEAD~1"} - }}}, - {"name": "security_scan", "description": "Run Atheon security scan", - "inputSchema": {"type": "object", "properties": { - "path": {"type": "string", "default": "."}, - "categories": {"type": "string", "default": "secrets,pii,security"} - }}}, - {"name": "list_repos", "description": "List accessible repositories", - "inputSchema": {"type": "object", "properties": {}}}, - {"name": "get_repo_config", "description": "Get CI configuration for a repo", - "inputSchema": {"type": "object", "properties": {"repo": {"type": "string"}}}}, - {"name": "pr_create", "description": "Create a pull request", - "inputSchema": {"type": "object", "properties": { - "repo": {"type": "string"}, "title": {"type": "string"}, - "body": {"type": "string"}, "head": {"type": "string"}, "base": {"type": "string", "default": "main"} - }, "required": ["title"]}}, - {"name": "pr_merge", "description": "Merge a pull request", - "inputSchema": {"type": "object", "properties": { - "repo": {"type": "string"}, "pr_number": {"type": "number"}, - "method": {"type": "string", "enum": ["squash","merge","rebase"], "default": "squash"} - }, "required": ["pr_number"]}} - ] - })), + "tools/list" => JsonRpcResponse::ok( + req.id.clone(), + serde_json::json!({ + "tools": [ + {"name": "ci_run", "description": "Trigger a CI run with optional delta analysis", + "inputSchema": {"type": "object", "properties": { + "repo": {"type": "string"}, "branch": {"type": "string"}, + "delta": {"type": "boolean", "default": true}, + "scope": {"type": "string", "enum": ["trivial","fast","standard","full","heavy"]}, + "workflows": {"type": "string"} + }}}, + {"name": "ci_status", "description": "Get CI run status", + "inputSchema": {"type": "object", "properties": { + "repo": {"type": "string"}, "run_id": {"type": "string"}, "branch": {"type": "string"} + }}}, + {"name": "ci_cancel", "description": "Cancel a running CI job", + "inputSchema": {"type": "object", "properties": { + "repo": {"type": "string"}, "run_id": {"type": "string", "description": "Run ID to cancel"} + }, "required": ["run_id"]}}, + {"name": "delta_plan", "description": "Analyze git diff and preview what CI would run", + "inputSchema": {"type": "object", "properties": { + "repo_root": {"type": "string", "default": "."}, + "base_ref": {"type": "string", "default": "HEAD~1"} + }}}, + {"name": "security_scan", "description": "Run Atheon security scan", + "inputSchema": {"type": "object", "properties": { + "path": {"type": "string", "default": "."}, + "categories": {"type": "string", "default": "secrets,pii,security"} + }}}, + {"name": "list_repos", "description": "List accessible repositories", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "get_repo_config", "description": "Get CI configuration for a repo", + "inputSchema": {"type": "object", "properties": {"repo": {"type": "string"}}}}, + {"name": "pr_create", "description": "Create a pull request", + "inputSchema": {"type": "object", "properties": { + "repo": {"type": "string"}, "title": {"type": "string"}, + "body": {"type": "string"}, "head": {"type": "string"}, "base": {"type": "string", "default": "main"} + }, "required": ["title"]}}, + {"name": "pr_merge", "description": "Merge a pull request", + "inputSchema": {"type": "object", "properties": { + "repo": {"type": "string"}, "pr_number": {"type": "number"}, + "method": {"type": "string", "enum": ["squash","merge","rebase"], "default": "squash"} + }, "required": ["pr_number"]}} + ] + }), + ), "tools/call" => { - let tool_name = req.params.get("name") - .and_then(|v| v.as_str()).unwrap_or(""); - let arguments = req.params.get("arguments") - .and_then(|v| v.as_object()).cloned().unwrap_or_default(); - let call = crate::handlers::ToolCall { name: tool_name.to_string(), arguments }; + let tool_name = req + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = req + .params + .get("arguments") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let call = crate::handlers::ToolCall { + name: tool_name.to_string(), + arguments, + }; let result = handler.blocking_handle(call); JsonRpcResponse::ok(req.id.clone(), result) } @@ -130,9 +143,7 @@ impl McpServer { JsonRpcResponse::ok(req.id, serde_json::json!({"ok": true})) } - _ => { - JsonRpcResponse::err(req.id, -32601, format!("Method not found: {}", req.method)) - } + _ => JsonRpcResponse::err(req.id, -32601, format!("Method not found: {}", req.method)), } } } diff --git a/crates/gitforce-runner/src/agent.rs b/crates/gitforce-runner/src/agent.rs index 93eeca65..faec383a 100644 --- a/crates/gitforce-runner/src/agent.rs +++ b/crates/gitforce-runner/src/agent.rs @@ -2,7 +2,7 @@ use gitforce_common::{Error, Result, RunnerId}; use gitforce_db::models::Runner; -use gitforce_sandbox::{DockerSandbox}; +use gitforce_sandbox::DockerSandbox; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -127,7 +127,8 @@ impl RunnerAgent { pub async fn run(&self) -> Result<()> { *self.is_running.write().await = true; - let runner = self.runner + let runner = self + .runner .as_ref() .ok_or_else(|| Error::internal("runner not registered"))?; @@ -149,7 +150,10 @@ impl RunnerAgent { break; } tracing::debug!("runner {} sending heartbeat", heartbeat_runner_id); - let url = format!("{}/runners/{}/heartbeat", heartbeat_url, heartbeat_runner_id); + let url = format!( + "{}/runners/{}/heartbeat", + heartbeat_url, heartbeat_runner_id + ); if let Err(e) = heartbeat_client.post(&url).send().await { tracing::trace!("heartbeat failed: {}", e); } @@ -179,7 +183,8 @@ impl RunnerAgent { for job in jobs { tracing::info!( "received job assignment: {} ({})", - job.name, job.job_id + job.name, + job.job_id ); // Job execution would happen here } @@ -204,7 +209,11 @@ impl RunnerAgent { /// Stop the runner agent pub async fn stop(&self) { *self.is_running.write().await = false; - let runner_id = self.runner.as_ref().map(|r| r.id.to_string()).unwrap_or_default(); + let runner_id = self + .runner + .as_ref() + .map(|r| r.id.to_string()) + .unwrap_or_default(); tracing::info!("runner {} stopped", runner_id); } @@ -670,9 +679,7 @@ mod tests { let agent_clone = agent.clone(); // Start run in background - let run_handle = tokio::spawn(async move { - agent_clone.run().await - }); + let run_handle = tokio::spawn(async move { agent_clone.run().await }); // Wait for start tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/crates/gitforce-runner/src/executor.rs b/crates/gitforce-runner/src/executor.rs index bd3d0eaf..c714b9a3 100644 --- a/crates/gitforce-runner/src/executor.rs +++ b/crates/gitforce-runner/src/executor.rs @@ -98,7 +98,11 @@ impl JobExecutor { tracing::info!("executing job {}", job_id); // Create sandbox instance - let instance = match self.sandbox.create(job_id, &job.image, SandboxLimits::default()).await { + let instance = match self + .sandbox + .create(job_id, &job.image, SandboxLimits::default()) + .await + { Ok(i) => i, Err(e) => { return JobResult { @@ -137,7 +141,11 @@ impl JobExecutor { if step_result.exit_code != 0 { success = false; final_exit_code = step_result.exit_code; - tracing::error!("step {} failed with exit code {}", step.name, step_result.exit_code); + tracing::error!( + "step {} failed with exit code {}", + step.name, + step_result.exit_code + ); break; } } @@ -172,7 +180,11 @@ impl JobExecutor { success, exit_code: final_exit_code, step_results, - error: if success { None } else { Some("job failed".to_string()) }, + error: if success { + None + } else { + Some("job failed".to_string()) + }, } } @@ -210,8 +222,8 @@ mod tests { JobStep::new("build", "cargo build"), JobStep::new("test", "cargo test"), ]; - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_steps(steps.clone()); + let job = + ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_steps(steps.clone()); assert_eq!(job.steps.len(), 2); assert_eq!(job.steps[0].name, "build"); } @@ -220,15 +232,13 @@ mod tests { fn test_executable_job_with_env() { let mut env = HashMap::new(); env.insert("RUST_BACKTRACE".to_string(), "1".to_string()); - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_env(env); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_env(env); assert_eq!(job.env.get("RUST_BACKTRACE"), Some(&"1".to_string())); } #[test] fn test_executable_job_with_timeout() { - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_timeout(7200); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_timeout(7200); assert_eq!(job.timeout_secs, 7200); } @@ -298,7 +308,10 @@ mod tests { env: Some(env), working_directory: Some("/tmp".to_string()), }; - assert_eq!(step.env.as_ref().unwrap().get("KEY"), Some(&"value".to_string())); + assert_eq!( + step.env.as_ref().unwrap().get("KEY"), + Some(&"value".to_string()) + ); assert_eq!(step.working_directory, Some("/tmp".to_string())); } @@ -384,8 +397,7 @@ mod tests { JobStep::new("step2", "echo 2"), JobStep::new("step3", "echo 3"), ]; - let job = ExecutableJob::new(JobId::new(), "alpine:latest".to_string()) - .with_steps(steps); + let job = ExecutableJob::new(JobId::new(), "alpine:latest".to_string()).with_steps(steps); assert_eq!(job.steps.len(), 3); } @@ -534,8 +546,7 @@ mod tests { #[test] fn test_executable_job_with_empty_steps() { - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_steps(vec![]); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_steps(vec![]); assert!(job.steps.is_empty()); } @@ -560,8 +571,7 @@ mod tests { fn test_executable_job_clone_and_modify() { let mut env = HashMap::new(); env.insert("FOO".to_string(), "bar".to_string()); - let job1 = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_env(env); + let job1 = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_env(env); let job2 = job1.clone(); // Verify both have same values assert_eq!(job1.image, job2.image); @@ -579,22 +589,20 @@ mod tests { #[test] fn test_executable_job_with_empty_env() { - let job = ExecutableJob::new(JobId::new(), "alpine:latest".to_string()) - .with_env(HashMap::new()); + let job = + ExecutableJob::new(JobId::new(), "alpine:latest".to_string()).with_env(HashMap::new()); assert!(job.env.is_empty()); } #[test] fn test_executable_job_very_long_timeout() { - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_timeout(86400); // 24 hours + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_timeout(86400); // 24 hours assert_eq!(job.timeout_secs, 86400); } #[test] fn test_executable_job_zero_timeout() { - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_timeout(0); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_timeout(0); assert_eq!(job.timeout_secs, 0); } @@ -617,8 +625,7 @@ mod tests { fn test_executable_job_with_unicode_in_env() { let mut env = HashMap::new(); env.insert("中文".to_string(), "value".to_string()); - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_env(env); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_env(env); assert_eq!(job.env.get("中文"), Some(&"value".to_string())); } @@ -633,8 +640,7 @@ mod tests { let mut env = HashMap::new(); env.insert("MY_VAR_123".to_string(), "value".to_string()); env.insert("ANOTHER_VAR".to_string(), "another".to_string()); - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_env(env); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_env(env); assert_eq!(job.env.len(), 2); } @@ -662,8 +668,7 @@ mod tests { #[test] fn test_executable_job_preserves_timeout() { - let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()) - .with_timeout(7200); + let job = ExecutableJob::new(JobId::new(), "rust:latest".to_string()).with_timeout(7200); assert_eq!(job.timeout_secs, 7200); } @@ -692,9 +697,7 @@ mod tests { } let job = ExecutableJob::new(JobId::new(), "alpine:latest".to_string()) - .with_steps(vec![ - JobStep::new("test", "echo hello"), - ]); + .with_steps(vec![JobStep::new("test", "echo hello")]); let result = executor.execute(job).await; // Job completed - success or failure depends on Docker availability @@ -715,9 +718,7 @@ mod tests { env.insert("TEST_VAR".to_string(), "test_value".to_string()); let job = ExecutableJob::new(JobId::new(), "alpine:latest".to_string()) - .with_steps(vec![ - JobStep::new("env", "echo $TEST_VAR"), - ]) + .with_steps(vec![JobStep::new("env", "echo $TEST_VAR")]) .with_env(env); let result = executor.execute(job).await; @@ -796,9 +797,21 @@ mod tests { fn test_job_result_multiple_step_results() { use gitforce_sandbox::StepResult; let step_results = vec![ - StepResult { exit_code: 0, stdout: "build success".to_string(), stderr: String::new() }, - StepResult { exit_code: 0, stdout: "test success".to_string(), stderr: String::new() }, - StepResult { exit_code: 0, stdout: "deploy success".to_string(), stderr: String::new() }, + StepResult { + exit_code: 0, + stdout: "build success".to_string(), + stderr: String::new(), + }, + StepResult { + exit_code: 0, + stdout: "test success".to_string(), + stderr: String::new(), + }, + StepResult { + exit_code: 0, + stdout: "deploy success".to_string(), + stderr: String::new(), + }, ]; let result = JobResult { job_id: JobId::new(), @@ -846,7 +859,10 @@ mod tests { working_directory: Some("/dir".to_string()), }; assert_eq!(step.name, "full-step"); - assert_eq!(step.env.as_ref().unwrap().get("VAR"), Some(&"val".to_string())); + assert_eq!( + step.env.as_ref().unwrap().get("VAR"), + Some(&"val".to_string()) + ); assert_eq!(step.working_directory, Some("/dir".to_string())); } diff --git a/crates/gitforce-sandbox/src/docker.rs b/crates/gitforce-sandbox/src/docker.rs index 69cc5096..1d0af539 100644 --- a/crates/gitforce-sandbox/src/docker.rs +++ b/crates/gitforce-sandbox/src/docker.rs @@ -3,13 +3,12 @@ use crate::limits::SandboxLimits; use async_trait::async_trait; use bollard::container::{ - Config, CreateContainerOptions, LogOutput, RemoveContainerOptions, - StartContainerOptions, + Config, CreateContainerOptions, LogOutput, RemoveContainerOptions, StartContainerOptions, }; -use bollard::image::CreateImageOptions; use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults}; -use bollard::Docker; +use bollard::image::CreateImageOptions; use bollard::models::HostConfig; +use bollard::Docker; use futures_util::StreamExt; use gitforce_common::{Error, JobId, Result}; @@ -110,7 +109,12 @@ impl DockerSandbox { #[async_trait] pub trait Sandbox: Send + Sync { /// Create a new sandbox instance - async fn create(&self, job_id: JobId, image: &str, limits: SandboxLimits) -> Result; + async fn create( + &self, + job_id: JobId, + image: &str, + limits: SandboxLimits, + ) -> Result; /// Execute a command in the sandbox async fn execute(&self, instance: &SandboxInstance, command: &[&str]) -> Result; @@ -121,7 +125,12 @@ pub trait Sandbox: Send + Sync { #[async_trait] impl Sandbox for DockerSandbox { - async fn create(&self, job_id: JobId, image: &str, limits: SandboxLimits) -> Result { + async fn create( + &self, + job_id: JobId, + image: &str, + limits: SandboxLimits, + ) -> Result { if let Some(ref docker) = self.docker { // Ensure image is available self.ensure_image(image).await?; @@ -133,7 +142,11 @@ impl Sandbox for DockerSandbox { memory: Some((limits.memory_mb * 1024 * 1024) as i64), cpu_period: Some(100000), // 100ms in microseconds cpu_quota: Some((limits.cpu_ms * 1000) as i64), // Convert ms to microseconds - network_mode: if limits.network { None } else { Some("none".to_string()) }, + network_mode: if limits.network { + None + } else { + Some("none".to_string()) + }, ..Default::default() }; @@ -150,11 +163,15 @@ impl Sandbox for DockerSandbox { platform: None, }; - let response = docker.create_container(Some(options), config).await + let response = docker + .create_container(Some(options), config) + .await .map_err(|e| Error::sandbox(format!("failed to create container: {}", e)))?; // Start container - docker.start_container(&response.id, None::>).await + docker + .start_container(&response.id, None::>) + .await .map_err(|e| Error::sandbox(format!("failed to start container: {}", e)))?; tracing::info!("Created container {} for job {}", response.id, job_id); @@ -182,11 +199,15 @@ impl Sandbox for DockerSandbox { ..Default::default() }; - let exec = docker.create_exec(&instance.container_id, config).await + let exec = docker + .create_exec(&instance.container_id, config) + .await .map_err(|e| Error::sandbox(format!("failed to create exec: {}", e)))?; // Start exec and get results - let result = docker.start_exec(&exec.id, None::).await + let result = docker + .start_exec(&exec.id, None::) + .await .map_err(|e| Error::sandbox(format!("failed to start exec: {}", e)))?; let mut stdout = String::new(); @@ -244,10 +265,16 @@ impl Sandbox for DockerSandbox { ..Default::default() }; - docker.remove_container(&instance.container_id, Some(options)).await + docker + .remove_container(&instance.container_id, Some(options)) + .await .map_err(|e| Error::sandbox(format!("failed to remove container: {}", e)))?; - tracing::info!("Destroyed container {} for job {}", instance.container_id, instance.job_id); + tracing::info!( + "Destroyed container {} for job {}", + instance.container_id, + instance.job_id + ); } Ok(()) } @@ -278,7 +305,9 @@ mod tests { // Test with real Docker let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await; + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await; // If Docker has issues, we fall back to stub mode if instance.is_err() { @@ -306,7 +335,9 @@ mod tests { } let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await; + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await; if instance.is_err() { return; } @@ -330,13 +361,17 @@ mod tests { } let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await; + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await; if instance.is_err() { return; } let instance = instance.unwrap(); - let result = sandbox.execute(&instance, &["sh", "-c", "echo line1"]).await; + let result = sandbox + .execute(&instance, &["sh", "-c", "echo line1"]) + .await; if result.is_ok() { assert_eq!(result.unwrap().exit_code, 0); } @@ -353,11 +388,17 @@ mod tests { let job_id = JobId::new(); // Create container (will use stub) - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); assert!(!instance.container_id.is_empty()); // Execute command (will use stub) - let result = sandbox.execute(&instance, &["echo", "hello"]).await.unwrap(); + let result = sandbox + .execute(&instance, &["echo", "hello"]) + .await + .unwrap(); assert_eq!(result.exit_code, 0); // Destroy container (will use stub) @@ -397,10 +438,16 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Test with shell command - let result = sandbox.execute(&instance, &["sh", "-c", "echo hello && echo world"]).await.unwrap(); + let result = sandbox + .execute(&instance, &["sh", "-c", "echo hello && echo world"]) + .await + .unwrap(); assert_eq!(result.exit_code, 0); sandbox.destroy(instance).await.unwrap(); @@ -411,10 +458,16 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Multiple commands in one execution - let result = sandbox.execute(&instance, &["echo", "line1"]).await.unwrap(); + let result = sandbox + .execute(&instance, &["echo", "line1"]) + .await + .unwrap(); assert_eq!(result.exit_code, 0); sandbox.destroy(instance).await.unwrap(); @@ -435,7 +488,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); assert_eq!(instance.job_id, job_id); sandbox.destroy(instance).await.unwrap(); @@ -446,7 +502,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Empty command should still work in stub mode let result = sandbox.execute(&instance, &[]).await.unwrap(); @@ -460,7 +519,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "rust:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "rust:latest", SandboxLimits::default()) + .await + .unwrap(); let result = sandbox.execute(&instance, &["true"]).await.unwrap(); assert_eq!(result.exit_code, 0); @@ -473,7 +535,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); let result = sandbox.execute(&instance, &["false"]).await.unwrap(); // Stub mode always returns 0, even for false command @@ -491,9 +556,18 @@ mod tests { let job2 = JobId::new(); let job3 = JobId::new(); - let instance1 = sandbox.create(job1, "alpine:latest", SandboxLimits::default()).await.unwrap(); - let instance2 = sandbox.create(job2, "ubuntu:latest", SandboxLimits::default()).await.unwrap(); - let instance3 = sandbox.create(job3, "rust:latest", SandboxLimits::default()).await.unwrap(); + let instance1 = sandbox + .create(job1, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); + let instance2 = sandbox + .create(job2, "ubuntu:latest", SandboxLimits::default()) + .await + .unwrap(); + let instance3 = sandbox + .create(job3, "rust:latest", SandboxLimits::default()) + .await + .unwrap(); // Each should have unique container ID assert_ne!(instance1.container_id, instance2.container_id); @@ -509,7 +583,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Destroy twice should both succeed (idempotent in stub mode) sandbox.destroy(instance.clone()).await.unwrap(); @@ -522,7 +599,14 @@ mod tests { let job_id = JobId::new(); // Very long image name - let instance = sandbox.create(job_id, "registry.example.com/verylongnamed repository/imagename:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create( + job_id, + "registry.example.com/verylongnamed repository/imagename:latest", + SandboxLimits::default(), + ) + .await + .unwrap(); assert!(!instance.container_id.is_empty()); sandbox.destroy(instance).await.unwrap(); @@ -595,10 +679,16 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Test with special characters in command - let result = sandbox.execute(&instance, &["sh", "-c", "echo $'hello\nworld'"]).await.unwrap(); + let result = sandbox + .execute(&instance, &["sh", "-c", "echo $'hello\nworld'"]) + .await + .unwrap(); assert_eq!(result.exit_code, 0); sandbox.destroy(instance).await.unwrap(); @@ -609,10 +699,16 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Test with unicode - let result = sandbox.execute(&instance, &["echo", "hello world"]).await.unwrap(); + let result = sandbox + .execute(&instance, &["echo", "hello world"]) + .await + .unwrap(); assert_eq!(result.exit_code, 0); sandbox.destroy(instance).await.unwrap(); @@ -623,7 +719,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Test 'true' command (exit 0) let result = sandbox.execute(&instance, &["true"]).await.unwrap(); @@ -637,7 +736,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); // Container ID should start with expected prefix assert!(instance.container_id.starts_with("gitforce-job-")); @@ -651,7 +753,10 @@ mod tests { let sandbox = DockerSandbox::with_limits(SandboxLimits::default()); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits::default()).await.unwrap(); + let instance = sandbox + .create(job_id, "alpine:latest", SandboxLimits::default()) + .await + .unwrap(); assert!(!instance.container_id.is_empty()); sandbox.destroy(instance).await.unwrap(); @@ -665,10 +770,17 @@ mod tests { }); let job_id = JobId::new(); - let instance = sandbox.create(job_id, "alpine:latest", SandboxLimits { - network: false, - ..Default::default() - }).await.unwrap(); + let instance = sandbox + .create( + job_id, + "alpine:latest", + SandboxLimits { + network: false, + ..Default::default() + }, + ) + .await + .unwrap(); assert!(!instance.container_id.is_empty()); sandbox.destroy(instance).await.unwrap(); diff --git a/crates/gitforce-sandbox/src/limits.rs b/crates/gitforce-sandbox/src/limits.rs index 7f20dacf..74043695 100644 --- a/crates/gitforce-sandbox/src/limits.rs +++ b/crates/gitforce-sandbox/src/limits.rs @@ -20,9 +20,9 @@ pub struct SandboxLimits { impl Default for SandboxLimits { fn default() -> Self { Self { - cpu_ms: 3600000, // 1 hour - memory_mb: 4096, // 4GB - disk_mb: 10240, // 10GB + cpu_ms: 3600000, // 1 hour + memory_mb: 4096, // 4GB + disk_mb: 10240, // 10GB timeout_secs: 3600, // 1 hour network: true, } @@ -33,7 +33,7 @@ impl SandboxLimits { /// Create limits for a specific tier pub fn small() -> Self { Self { - cpu_ms: 300000, // 5 minutes + cpu_ms: 300000, // 5 minutes memory_mb: 512, disk_mb: 1024, timeout_secs: 300, @@ -127,7 +127,7 @@ mod tests { assert!(default.memory_mb <= large.memory_mb); // Verify network settings - assert!(!small.network); // Small has no network + assert!(!small.network); // Small has no network assert!(medium.network); assert!(large.network); assert!(default.network); diff --git a/crates/gitforce-scheduler/src/assigner.rs b/crates/gitforce-scheduler/src/assigner.rs index 66b0cfe0..0c357e77 100644 --- a/crates/gitforce-scheduler/src/assigner.rs +++ b/crates/gitforce-scheduler/src/assigner.rs @@ -32,10 +32,7 @@ pub enum SchedulerCommand { #[derive(Debug, Clone)] pub enum SchedulerEvent { /// Job assigned to runner - JobAssigned { - job_id: JobId, - runner_id: RunnerId, - }, + JobAssigned { job_id: JobId, runner_id: RunnerId }, /// No runner available for job NoRunnerAvailable { job_id: JobId }, } @@ -116,12 +113,7 @@ impl Scheduler { } /// Enqueue a job - pub async fn enqueue( - &self, - job_id: JobId, - pipeline_run_id: PipelineRunId, - repo_id: RepoId, - ) { + pub async fn enqueue(&self, job_id: JobId, pipeline_run_id: PipelineRunId, repo_id: RepoId) { let job = QueuedJob::new(job_id, pipeline_run_id, repo_id); let mut state = self.state.write().await; state.queue.enqueue(job); @@ -289,7 +281,9 @@ mod tests { let run_id = PipelineRunId::new(); let job_id = JobId::new(); - scheduler.enqueue_with_priority(job_id, run_id, repo_id, Priority::High).await; + scheduler + .enqueue_with_priority(job_id, run_id, repo_id, Priority::High) + .await; assert_eq!(scheduler.queue_len().await, 1); } @@ -366,7 +360,9 @@ mod tests { #[test] fn test_scheduler_event_debug() { - let evt = SchedulerEvent::NoRunnerAvailable { job_id: JobId::new() }; + let evt = SchedulerEvent::NoRunnerAvailable { + job_id: JobId::new(), + }; assert!(format!("{:?}", evt).contains("NoRunnerAvailable")); } @@ -542,7 +538,9 @@ mod tests { let repo_id = RepoId::new(); let run_id = PipelineRunId::new(); - scheduler.enqueue_with_priority(JobId::new(), run_id, repo_id, Priority::High).await; + scheduler + .enqueue_with_priority(JobId::new(), run_id, repo_id, Priority::High) + .await; assert_eq!(scheduler.queue_len().await, 1); } diff --git a/crates/gitforce-scheduler/src/lib.rs b/crates/gitforce-scheduler/src/lib.rs index 3015d643..13937628 100644 --- a/crates/gitforce-scheduler/src/lib.rs +++ b/crates/gitforce-scheduler/src/lib.rs @@ -2,12 +2,12 @@ //! //! Job queue management, runner assignment, and scheduling policies. -pub mod queue; -pub mod policy; pub mod assigner; +pub mod policy; +pub mod queue; pub mod server; -pub use queue::{JobQueue, Priority}; -pub use policy::{SchedulingPolicy, SimplePolicy}; pub use assigner::Scheduler; -pub use server::{SchedulerServerState, create_state, scheduler_routes}; +pub use policy::{SchedulingPolicy, SimplePolicy}; +pub use queue::{JobQueue, Priority}; +pub use server::{create_state, scheduler_routes, SchedulerServerState}; diff --git a/crates/gitforce-scheduler/src/policy.rs b/crates/gitforce-scheduler/src/policy.rs index e3062130..63392f7c 100644 --- a/crates/gitforce-scheduler/src/policy.rs +++ b/crates/gitforce-scheduler/src/policy.rs @@ -8,11 +8,7 @@ use gitforce_db::models::Runner; #[async_trait] pub trait SchedulingPolicy: Send + Sync { /// Select the best runner for a job - async fn select_runner( - &self, - job_id: JobId, - runners: &[Runner], - ) -> Option; + async fn select_runner(&self, job_id: JobId, runners: &[Runner]) -> Option; } /// Simple round-robin / least-loaded policy @@ -32,11 +28,7 @@ impl Default for SimplePolicy { #[async_trait] impl SchedulingPolicy for SimplePolicy { - async fn select_runner( - &self, - _job_id: JobId, - runners: &[Runner], - ) -> Option { + async fn select_runner(&self, _job_id: JobId, runners: &[Runner]) -> Option { // Filter to only online runners with capacity let available: Vec<_> = runners .iter() @@ -49,9 +41,7 @@ impl SchedulingPolicy for SimplePolicy { // Select the runner with most available capacity // In a real implementation, we'd track actual load - let best = available - .iter() - .max_by_key(|r| r.capacity)?; + let best = available.iter().max_by_key(|r| r.capacity)?; Some(best.id) } @@ -74,11 +64,7 @@ impl Default for PriorityPolicy { #[async_trait] impl SchedulingPolicy for PriorityPolicy { - async fn select_runner( - &self, - job_id: JobId, - runners: &[Runner], - ) -> Option { + async fn select_runner(&self, job_id: JobId, runners: &[Runner]) -> Option { // Use simple policy for now SimplePolicy::new().select_runner(job_id, runners).await } @@ -184,9 +170,7 @@ mod tests { #[tokio::test] async fn test_priority_policy_uses_simple_policy() { let policy = PriorityPolicy::new(); - let runners = vec![ - make_runner(RunnerId::new(), "runner-1", "online", 4), - ]; + let runners = vec![make_runner(RunnerId::new(), "runner-1", "online", 4)]; let job_id = JobId::new(); let selected = policy.select_runner(job_id, &runners).await; @@ -209,9 +193,7 @@ mod tests { async fn test_simple_policy_with_single_runner() { let policy = SimplePolicy::new(); let runner_id = RunnerId::new(); - let runners = vec![ - make_runner(runner_id, "solo-runner", "online", 1), - ]; + let runners = vec![make_runner(runner_id, "solo-runner", "online", 1)]; let job_id = JobId::new(); let selected = policy.select_runner(job_id, &runners).await; diff --git a/crates/gitforce-scheduler/src/queue.rs b/crates/gitforce-scheduler/src/queue.rs index d0cdf92a..e42d02cf 100644 --- a/crates/gitforce-scheduler/src/queue.rs +++ b/crates/gitforce-scheduler/src/queue.rs @@ -1,8 +1,8 @@ //! Job queue implementation use gitforce_common::{JobId, PipelineRunId, RepoId}; -use std::collections::{BinaryHeap, HashMap}; use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; /// Job priority #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/crates/gitforce-scheduler/src/server.rs b/crates/gitforce-scheduler/src/server.rs index eac02686..05db8a1a 100644 --- a/crates/gitforce-scheduler/src/server.rs +++ b/crates/gitforce-scheduler/src/server.rs @@ -77,13 +77,16 @@ async fn register_runner( tracing::info!("runner {} registered via HTTP", runner.id); - (StatusCode::CREATED, Json(serde_json::json!({ - "id": runner.id.to_string(), - "name": runner.name, - "type": runner.runner_type, - "status": runner.status, - "capacity": runner.capacity, - }))) + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": runner.id.to_string(), + "name": runner.name, + "type": runner.runner_type, + "status": runner.status, + "capacity": runner.capacity, + })), + ) } /// Runner heartbeat @@ -94,24 +97,28 @@ async fn runner_heartbeat( let runner_id: RunnerId = match Uuid::parse_str(&runner_id) { Ok(id) => RunnerId::from(id), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid_runner_id", - "message": "Invalid runner ID format" - }))) + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_runner_id", + "message": "Invalid runner ID format" + })), + ) } }; state.scheduler.heartbeat(runner_id).await; - (StatusCode::OK, Json(serde_json::json!({ - "status": "ok" - }))) + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok" + })), + ) } /// Get pending jobs for a runner -async fn get_pending_jobs( - State(state): State, -) -> impl IntoResponse { +async fn get_pending_jobs(State(state): State) -> impl IntoResponse { // Process queue to assign pending jobs state.scheduler.process_queue().await; @@ -129,10 +136,13 @@ async fn assign_job( let job_id: JobId = match Uuid::parse_str(&job_id) { Ok(id) => JobId::from(id), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid_job_id", - "message": "Invalid job ID format" - }))) + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_job_id", + "message": "Invalid job ID format" + })), + ) } }; @@ -145,10 +155,13 @@ async fn assign_job( tracing::info!("job {} assigned to runner {} via HTTP", job_id, r_id); } - (StatusCode::OK, Json(serde_json::json!({ - "status": "assigned", - "job_id": job_id.to_string() - }))) + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "assigned", + "job_id": job_id.to_string() + })), + ) } /// Complete a job @@ -160,20 +173,26 @@ async fn complete_job( let job_id: JobId = match Uuid::parse_str(&job_id) { Ok(id) => JobId::from(id), Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid_job_id", - "message": "Invalid job ID format" - }))) + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_job_id", + "message": "Invalid job ID format" + })), + ) } }; let success = request["success"].as_bool().unwrap_or(false); tracing::info!("job {} completed via HTTP: success={}", job_id, success); - (StatusCode::OK, Json(serde_json::json!({ - "status": "completed", - "job_id": job_id.to_string() - }))) + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "completed", + "job_id": job_id.to_string() + })), + ) } #[cfg(test)] @@ -183,7 +202,7 @@ mod tests { fn assert_status(response: axum::response::Response, expected: StatusCode) { let status = response.status(); - assert_eq!(status, expected, "Expected status {:?}, got {:?}", expected, status); + assert_eq!(status, expected, "HTTP response status mismatch"); } #[tokio::test] @@ -197,11 +216,7 @@ mod tests { capacity: 4, }; - let response = register_runner( - axum::extract::State(state), - axum::Json(request), - ) - .await; + let response = register_runner(axum::extract::State(state), axum::Json(request)).await; assert_status(response.into_response(), StatusCode::CREATED); } @@ -217,11 +232,7 @@ mod tests { capacity: 8, }; - let response = register_runner( - axum::extract::State(state), - axum::Json(request), - ) - .await; + let response = register_runner(axum::extract::State(state), axum::Json(request)).await; assert_status(response.into_response(), StatusCode::CREATED); } @@ -404,11 +415,7 @@ mod tests { capacity: 2, }; - let response = register_runner( - axum::extract::State(state), - axum::Json(request), - ) - .await; + let response = register_runner(axum::extract::State(state), axum::Json(request)).await; assert_status(response.into_response(), StatusCode::CREATED); } diff --git a/crates/gitforce-storage/src/artifact.rs b/crates/gitforce-storage/src/artifact.rs index 81534b10..fbd491f8 100644 --- a/crates/gitforce-storage/src/artifact.rs +++ b/crates/gitforce-storage/src/artifact.rs @@ -53,18 +53,19 @@ pub struct Artifact { impl Artifact { /// Create artifact metadata from a file pub async fn from_file(job_id: JobId, name: String, path: &PathBuf) -> Result { - let mut file = File::open(path).await.map_err(|e| { - Error::storage(format!("failed to open file for artifact: {}", e)) - })?; + let mut file = File::open(path) + .await + .map_err(|e| Error::storage(format!("failed to open file for artifact: {}", e)))?; let mut hasher = Sha256::new(); let mut size: u64 = 0; let mut buffer = vec![0u8; 8192]; loop { - let bytes_read = file.read(&mut buffer).await.map_err(|e| { - Error::storage(format!("failed to read file for artifact: {}", e)) - })?; + let bytes_read = file + .read(&mut buffer) + .await + .map_err(|e| Error::storage(format!("failed to read file for artifact: {}", e)))?; if bytes_read == 0 { break; @@ -197,7 +198,10 @@ mod tests { hasher.update(data); let result = hex::encode(hasher.finalize()); // SHA256 of "hello world" - assert_eq!(result, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"); + assert_eq!( + result, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); } #[test] @@ -312,8 +316,8 @@ mod tests { #[tokio::test] async fn test_artifact_from_file() { - use tokio::fs; use tempfile::tempdir; + use tokio::fs; // Create a temp file with known content let dir = tempdir().unwrap(); @@ -322,37 +326,52 @@ mod tests { fs::write(&file_path, content).await.unwrap(); let job_id = JobId::new(); - let artifact = Artifact::from_file(job_id, "test-file.txt".to_string(), &file_path).await.unwrap(); + let artifact = Artifact::from_file(job_id, "test-file.txt".to_string(), &file_path) + .await + .unwrap(); assert_eq!(artifact.job_id, job_id); assert_eq!(artifact.name, "test-file.txt"); assert_eq!(artifact.size_bytes, content.len() as u64); // SHA256 of "hello world" - assert_eq!(artifact.checksum, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"); + assert_eq!( + artifact.checksum, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); assert!(artifact.content_type.is_none()); } #[tokio::test] async fn test_artifact_from_file_empty() { - use tokio::fs; use tempfile::tempdir; + use tokio::fs; let dir = tempdir().unwrap(); let file_path = dir.path().join("empty.txt"); fs::write(&file_path, b"").await.unwrap(); - let artifact = Artifact::from_file(JobId::new(), "empty.txt".to_string(), &file_path).await.unwrap(); + let artifact = Artifact::from_file(JobId::new(), "empty.txt".to_string(), &file_path) + .await + .unwrap(); assert_eq!(artifact.size_bytes, 0); // SHA256 of empty string - assert_eq!(artifact.checksum, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert_eq!( + artifact.checksum, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); } #[tokio::test] async fn test_artifact_from_file_not_found() { use std::path::PathBuf; - let result = Artifact::from_file(JobId::new(), "missing.txt".to_string(), &PathBuf::from("/nonexistent/path/file.txt")).await; + let result = Artifact::from_file( + JobId::new(), + "missing.txt".to_string(), + &PathBuf::from("/nonexistent/path/file.txt"), + ) + .await; assert!(result.is_err()); } } diff --git a/crates/gitforce-storage/src/filesystem.rs b/crates/gitforce-storage/src/filesystem.rs index ed6a768c..46a11ce4 100644 --- a/crates/gitforce-storage/src/filesystem.rs +++ b/crates/gitforce-storage/src/filesystem.rs @@ -24,13 +24,13 @@ impl FileStorage { let cache_dir = root.join("cache"); // Create directories - fs::create_dir_all(&artifacts_dir).await.map_err(|e| { - Error::storage(format!("failed to create artifacts directory: {}", e)) - })?; + fs::create_dir_all(&artifacts_dir) + .await + .map_err(|e| Error::storage(format!("failed to create artifacts directory: {}", e)))?; - fs::create_dir_all(&cache_dir).await.map_err(|e| { - Error::storage(format!("failed to create cache directory: {}", e)) - })?; + fs::create_dir_all(&cache_dir) + .await + .map_err(|e| Error::storage(format!("failed to create cache directory: {}", e)))?; Ok(Self { root, @@ -67,26 +67,26 @@ impl ArtifactStore for FileStorage { let meta_path = self.artifact_meta_path(artifact.id); // Write data - let mut file = fs::File::create(&artifact_path).await.map_err(|e| { - Error::storage(format!("failed to create artifact file: {}", e)) - })?; + let mut file = fs::File::create(&artifact_path) + .await + .map_err(|e| Error::storage(format!("failed to create artifact file: {}", e)))?; - file.write_all(data).await.map_err(|e| { - Error::storage(format!("failed to write artifact data: {}", e)) - })?; + file.write_all(data) + .await + .map_err(|e| Error::storage(format!("failed to write artifact data: {}", e)))?; // Write metadata - let meta_json = serde_json::to_string_pretty(artifact).map_err(|e| { - Error::storage(format!("failed to serialize artifact metadata: {}", e)) - })?; + let meta_json = serde_json::to_string_pretty(artifact) + .map_err(|e| Error::storage(format!("failed to serialize artifact metadata: {}", e)))?; let mut meta_file = fs::File::create(&meta_path).await.map_err(|e| { Error::storage(format!("failed to create artifact metadata file: {}", e)) })?; - meta_file.write_all(meta_json.as_bytes()).await.map_err(|e| { - Error::storage(format!("failed to write artifact metadata: {}", e)) - })?; + meta_file + .write_all(meta_json.as_bytes()) + .await + .map_err(|e| Error::storage(format!("failed to write artifact metadata: {}", e)))?; tracing::info!( "stored artifact {} ({} bytes)", @@ -99,14 +99,14 @@ impl ArtifactStore for FileStorage { async fn get(&self, id: ArtifactId) -> Result> { let path = self.artifact_path(id); - let mut file = fs::File::open(&path).await.map_err(|e| { - Error::storage(format!("failed to open artifact file: {}", e)) - })?; + let mut file = fs::File::open(&path) + .await + .map_err(|e| Error::storage(format!("failed to open artifact file: {}", e)))?; let mut data = Vec::new(); - file.read_to_end(&mut data).await.map_err(|e| { - Error::storage(format!("failed to read artifact data: {}", e)) - })?; + file.read_to_end(&mut data) + .await + .map_err(|e| Error::storage(format!("failed to read artifact data: {}", e)))?; Ok(data) } @@ -116,9 +116,9 @@ impl ArtifactStore for FileStorage { let meta_path = self.artifact_meta_path(id); if artifact_path.exists() { - fs::remove_file(&artifact_path).await.map_err(|e| { - Error::storage(format!("failed to delete artifact file: {}", e)) - })?; + fs::remove_file(&artifact_path) + .await + .map_err(|e| Error::storage(format!("failed to delete artifact file: {}", e)))?; } if meta_path.exists() { @@ -134,18 +134,17 @@ impl ArtifactStore for FileStorage { async fn get_metadata(&self, id: ArtifactId) -> Result { let meta_path = self.artifact_meta_path(id); - let mut file = fs::File::open(&meta_path).await.map_err(|e| { - Error::storage(format!("failed to open artifact metadata file: {}", e)) - })?; + let mut file = fs::File::open(&meta_path) + .await + .map_err(|e| Error::storage(format!("failed to open artifact metadata file: {}", e)))?; let mut contents = Vec::new(); - file.read_to_end(&mut contents).await.map_err(|e| { - Error::storage(format!("failed to read artifact metadata: {}", e)) - })?; + file.read_to_end(&mut contents) + .await + .map_err(|e| Error::storage(format!("failed to read artifact metadata: {}", e)))?; - let artifact: Artifact = serde_json::from_slice(&contents).map_err(|e| { - Error::storage(format!("failed to parse artifact metadata: {}", e)) - })?; + let artifact: Artifact = serde_json::from_slice(&contents) + .map_err(|e| Error::storage(format!("failed to parse artifact metadata: {}", e)))?; Ok(artifact) } @@ -153,13 +152,15 @@ impl ArtifactStore for FileStorage { async fn list(&self) -> Result> { let mut artifacts = Vec::new(); - let mut entries = fs::read_dir(&self.artifacts_dir).await.map_err(|e| { - Error::storage(format!("failed to read artifacts directory: {}", e)) - })?; + let mut entries = fs::read_dir(&self.artifacts_dir) + .await + .map_err(|e| Error::storage(format!("failed to read artifacts directory: {}", e)))?; - while let Some(entry) = entries.next_entry().await.map_err(|e| { - Error::storage(format!("failed to read artifact entry: {}", e)) - })? { + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| Error::storage(format!("failed to read artifact entry: {}", e)))? + { let path = entry.path(); // Check if filename ends with .meta.json if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { @@ -181,7 +182,10 @@ impl ArtifactStore for FileStorage { async fn list_by_job(&self, job_id: gitforce_common::JobId) -> Result> { let all_artifacts = ArtifactStore::list(self).await?; - Ok(all_artifacts.into_iter().filter(|a| a.job_id == job_id).collect()) + Ok(all_artifacts + .into_iter() + .filter(|a| a.job_id == job_id) + .collect()) } } @@ -191,13 +195,13 @@ impl CacheStore for FileStorage { let path = self.cache_path(&key); let meta_path = self.cache_meta_path(&key); - let mut file = fs::File::create(&path).await.map_err(|e| { - Error::storage(format!("failed to create cache file: {}", e)) - })?; + let mut file = fs::File::create(&path) + .await + .map_err(|e| Error::storage(format!("failed to create cache file: {}", e)))?; - file.write_all(&data).await.map_err(|e| { - Error::storage(format!("failed to write cache data: {}", e)) - })?; + file.write_all(&data) + .await + .map_err(|e| Error::storage(format!("failed to write cache data: {}", e)))?; // Write metadata let now = chrono::Utc::now(); @@ -208,17 +212,17 @@ impl CacheStore for FileStorage { accessed_at: now, }; - let meta_json = serde_json::to_string(&entry).map_err(|e| { - Error::storage(format!("failed to serialize cache metadata: {}", e)) - })?; + let meta_json = serde_json::to_string(&entry) + .map_err(|e| Error::storage(format!("failed to serialize cache metadata: {}", e)))?; - let mut meta_file = fs::File::create(&meta_path).await.map_err(|e| { - Error::storage(format!("failed to create cache metadata file: {}", e)) - })?; + let mut meta_file = fs::File::create(&meta_path) + .await + .map_err(|e| Error::storage(format!("failed to create cache metadata file: {}", e)))?; - meta_file.write_all(meta_json.as_bytes()).await.map_err(|e| { - Error::storage(format!("failed to write cache metadata: {}", e)) - })?; + meta_file + .write_all(meta_json.as_bytes()) + .await + .map_err(|e| Error::storage(format!("failed to write cache metadata: {}", e)))?; tracing::debug!("cached {} bytes at {:?}", data.len(), path); Ok(()) @@ -231,14 +235,14 @@ impl CacheStore for FileStorage { return Ok(None); } - let mut file = fs::File::open(&path).await.map_err(|e| { - Error::storage(format!("failed to open cache file: {}", e)) - })?; + let mut file = fs::File::open(&path) + .await + .map_err(|e| Error::storage(format!("failed to open cache file: {}", e)))?; let mut data = Vec::new(); - file.read_to_end(&mut data).await.map_err(|e| { - Error::storage(format!("failed to read cache data: {}", e)) - })?; + file.read_to_end(&mut data) + .await + .map_err(|e| Error::storage(format!("failed to read cache data: {}", e)))?; Ok(Some(data)) } @@ -248,9 +252,9 @@ impl CacheStore for FileStorage { let meta_path = self.cache_meta_path(key); if path.exists() { - fs::remove_file(&path).await.map_err(|e| { - Error::storage(format!("failed to delete cache file: {}", e)) - })?; + fs::remove_file(&path) + .await + .map_err(|e| Error::storage(format!("failed to delete cache file: {}", e)))?; } if meta_path.exists() { @@ -265,20 +269,23 @@ impl CacheStore for FileStorage { async fn list(&self) -> Result> { let mut entries = Vec::new(); - let mut dir_entries = fs::read_dir(&self.cache_dir).await.map_err(|e| { - Error::storage(format!("failed to read cache directory: {}", e)) - })?; + let mut dir_entries = fs::read_dir(&self.cache_dir) + .await + .map_err(|e| Error::storage(format!("failed to read cache directory: {}", e)))?; - while let Some(entry) = dir_entries.next_entry().await.map_err(|e| { - Error::storage(format!("failed to read cache entry: {}", e)) - })? { + while let Some(entry) = dir_entries + .next_entry() + .await + .map_err(|e| Error::storage(format!("failed to read cache entry: {}", e)))? + { let path = entry.path(); if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { if filename.ends_with(".meta.json") { let mut contents = Vec::new(); if let Ok(mut file) = fs::File::open(&path).await { if file.read_to_end(&mut contents).await.is_ok() { - if let Ok(cache_entry) = serde_json::from_slice::(&contents) { + if let Ok(cache_entry) = serde_json::from_slice::(&contents) + { entries.push(cache_entry); } } @@ -313,14 +320,18 @@ mod tests { }; // Put using explicit trait method call to disambiguate - ArtifactStore::put(&storage, &artifact, b"hello world").await.unwrap(); + ArtifactStore::put(&storage, &artifact, b"hello world") + .await + .unwrap(); // Get using explicit trait method call to disambiguate let data = ArtifactStore::get(&storage, artifact.id).await.unwrap(); assert_eq!(data, b"hello world"); // Get metadata - let meta = ArtifactStore::get_metadata(&storage, artifact.id).await.unwrap(); + let meta = ArtifactStore::get_metadata(&storage, artifact.id) + .await + .unwrap(); assert_eq!(meta.name, "test-artifact"); // Delete using explicit trait method call @@ -338,7 +349,9 @@ mod tests { let key = CacheKey::new(repo_id, "test-key", "main"); let data = b"cache content"; - CacheStore::put(&storage, key.clone(), data.to_vec()).await.unwrap(); + CacheStore::put(&storage, key.clone(), data.to_vec()) + .await + .unwrap(); let retrieved = CacheStore::get(&storage, &key).await.unwrap(); assert!(retrieved.is_some()); @@ -363,7 +376,9 @@ mod tests { let repo_id = gitforce_common::RepoId::new(); let key = CacheKey::new(repo_id, "delete-me", "main"); - CacheStore::put(&storage, key.clone(), b"data".to_vec()).await.unwrap(); + CacheStore::put(&storage, key.clone(), b"data".to_vec()) + .await + .unwrap(); // Verify it exists let result = CacheStore::get(&storage, &key).await.unwrap(); @@ -394,8 +409,12 @@ mod tests { assert!(entries.is_empty()); // Add entries - CacheStore::put(&storage, key1.clone(), b"data1".to_vec()).await.unwrap(); - CacheStore::put(&storage, key2.clone(), b"data2".to_vec()).await.unwrap(); + CacheStore::put(&storage, key1.clone(), b"data1".to_vec()) + .await + .unwrap(); + CacheStore::put(&storage, key2.clone(), b"data2".to_vec()) + .await + .unwrap(); // Now list should return entries let entries = CacheStore::list(&storage).await.unwrap(); @@ -441,7 +460,9 @@ mod tests { }; let data = format!("content{}", i); - ArtifactStore::put(&storage, &artifact, data.as_bytes()).await.unwrap(); + ArtifactStore::put(&storage, &artifact, data.as_bytes()) + .await + .unwrap(); let retrieved = ArtifactStore::get(&storage, artifact.id).await.unwrap(); assert_eq!(retrieved, data.as_bytes()); @@ -464,14 +485,21 @@ mod tests { created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact, b"test data").await.unwrap(); + ArtifactStore::put(&storage, &artifact, b"test data") + .await + .unwrap(); // Get metadata - let meta = ArtifactStore::get_metadata(&storage, artifact.id).await.unwrap(); + let meta = ArtifactStore::get_metadata(&storage, artifact.id) + .await + .unwrap(); assert_eq!(meta.name, "metadata-test"); assert_eq!(meta.checksum, "abc123"); assert_eq!(meta.size_bytes, 42); - assert_eq!(meta.content_type, Some("application/octet-stream".to_string())); + assert_eq!( + meta.content_type, + Some("application/octet-stream".to_string()) + ); } #[tokio::test] @@ -485,7 +513,9 @@ mod tests { for i in 0..3 { let key = CacheKey::new(repo_id, &format!("key-{}", i), "main"); let data = format!("value{}", i); - CacheStore::put(&storage, key.clone(), data.into_bytes()).await.unwrap(); + CacheStore::put(&storage, key.clone(), data.into_bytes()) + .await + .unwrap(); } // Get them back @@ -535,7 +565,9 @@ mod tests { created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact, b"special content").await.unwrap(); + ArtifactStore::put(&storage, &artifact, b"special content") + .await + .unwrap(); let retrieved = ArtifactStore::get(&storage, artifact.id).await.unwrap(); assert_eq!(retrieved, b"special content"); } @@ -549,12 +581,16 @@ mod tests { let key = CacheKey::new(repo_id, "overwrite-test", "main"); // Put first value - CacheStore::put(&storage, key.clone(), b"first".to_vec()).await.unwrap(); + CacheStore::put(&storage, key.clone(), b"first".to_vec()) + .await + .unwrap(); let first = CacheStore::get(&storage, &key).await.unwrap(); assert_eq!(first.unwrap(), b"first"); // Overwrite with second value - CacheStore::put(&storage, key.clone(), b"second".to_vec()).await.unwrap(); + CacheStore::put(&storage, key.clone(), b"second".to_vec()) + .await + .unwrap(); let second = CacheStore::get(&storage, &key).await.unwrap(); assert_eq!(second.unwrap(), b"second"); } @@ -579,7 +615,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact, b"test data").await.unwrap(); + ArtifactStore::put(&storage, &artifact, b"test data") + .await + .unwrap(); // List should have one artifact let artifacts = ArtifactStore::list(&storage).await.unwrap(); @@ -606,7 +644,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact1, b"data1").await.unwrap(); + ArtifactStore::put(&storage, &artifact1, b"data1") + .await + .unwrap(); // Add artifact for other_job_id let artifact2 = Artifact { @@ -619,7 +659,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact2, b"data2").await.unwrap(); + ArtifactStore::put(&storage, &artifact2, b"data2") + .await + .unwrap(); // List by job_id should return only artifact1 let artifacts = ArtifactStore::list_by_job(&storage, job_id).await.unwrap(); @@ -627,7 +669,9 @@ mod tests { assert_eq!(artifacts[0].name, "job-artifact"); // List by other_job_id should return only artifact2 - let artifacts = ArtifactStore::list_by_job(&storage, other_job_id).await.unwrap(); + let artifacts = ArtifactStore::list_by_job(&storage, other_job_id) + .await + .unwrap(); assert_eq!(artifacts.len(), 1); assert_eq!(artifacts[0].name, "other-job-artifact"); } @@ -663,7 +707,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact, format!("data{}", i).as_bytes()).await.unwrap(); + ArtifactStore::put(&storage, &artifact, format!("data{}", i).as_bytes()) + .await + .unwrap(); } let artifacts = ArtifactStore::list_by_job(&storage, job_id).await.unwrap(); @@ -690,7 +736,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact, format!("data{}", i).as_bytes()).await.unwrap(); + ArtifactStore::put(&storage, &artifact, format!("data{}", i).as_bytes()) + .await + .unwrap(); } // list() should return all artifacts @@ -732,7 +780,9 @@ mod tests { }; // First write - ArtifactStore::put(&storage, &artifact, b"original data").await.unwrap(); + ArtifactStore::put(&storage, &artifact, b"original data") + .await + .unwrap(); let data1 = ArtifactStore::get(&storage, artifact_id).await.unwrap(); assert_eq!(data1, b"original data"); @@ -747,7 +797,9 @@ mod tests { content_type: None, created_at: chrono::Utc::now(), }; - ArtifactStore::put(&storage, &artifact2, b"updated data").await.unwrap(); + ArtifactStore::put(&storage, &artifact2, b"updated data") + .await + .unwrap(); let data2 = ArtifactStore::get(&storage, artifact_id).await.unwrap(); assert_eq!(data2, b"updated data"); } diff --git a/docs/AMORTYX_INTEGRATION.md b/docs/AMORTYX_INTEGRATION.md index 7bb180c7..e8d2387f 100644 --- a/docs/AMORTYX_INTEGRATION.md +++ b/docs/AMORTYX_INTEGRATION.md @@ -133,7 +133,8 @@ When you set up the Mac Mini for macOS builds: ```bash # API Gateway -JWT_SECRET="your-secret-here" +# Export JWT_SECRET from a secret manager before starting the service. +export JWT_SECRET PORT=8080 DATABASE_URL="sqlite:/gitforge.db" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0dec8409..e62ad737 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -85,7 +85,7 @@ runner-2: For production, use PostgreSQL: ```toml [database] -url = "postgres://gitforge:password@postgres:5432/gitforge" +url = "postgres://gitforge:${POSTGRES_PASSWORD}@postgres:5432/gitforge" ``` ## Monitoring diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 3519092d..ecdc21d8 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -45,7 +45,8 @@ The API gateway exposes the REST API for frontend integration. cargo run -p api # Production -JWT_SECRET=your-secret ./target/release/api --host 0.0.0.0 --port 8080 +export JWT_SECRET +./target/release/api --host 0.0.0.0 --port 8080 ``` **Environment Variables:** @@ -179,7 +180,7 @@ SQLite doesn't support concurrent writes. For multi-runner setups, use PostgreSQ ```toml [database] -url = "postgres://gitforge:password@localhost:5432/gitforge" +url = "postgres://gitforge:${POSTGRES_PASSWORD}@localhost:5432/gitforge" ``` ## Development @@ -221,7 +222,7 @@ port = 8080 url = "sqlite:/data/gitforge.db" [auth] -jwt_secret = "your-secret-here" +jwt_secret = "" [runner] scheduler_url = "http://scheduler:8081" diff --git a/scripts/setup-services.sh b/scripts/setup-services.sh index c5e00cbd..bf644f79 100755 --- a/scripts/setup-services.sh +++ b/scripts/setup-services.sh @@ -39,7 +39,6 @@ sleep 2 echo "Starting API service..." cd "$INSTALL_DIR" DATABASE_URL="sqlite:///nas/Temp/repos/GitForge/data/gitforge.db?mode=rwc" \ -JWT_SECRET="dev-secret-change-in-prod-min-32-chars!!" \ RUST_LOG=info \ ./target/release/api > "$LOGS_DIR/api.log" 2>&1 & API_PID=$! @@ -98,4 +97,4 @@ echo "$API_PID" > "$DATA_DIR/api.pid" echo "$CI_PID" > "$DATA_DIR/ci.pid" echo "$GIT_PID" > "$DATA_DIR/git.pid" -echo "Run './scripts/stop-services.sh' to stop all services" \ No newline at end of file +echo "Run './scripts/stop-services.sh' to stop all services" diff --git a/services/api/src/main.rs b/services/api/src/main.rs index 276c3b27..f1cd31e0 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -82,13 +82,14 @@ pub struct ServerConfig { /// Load server configuration from environment pub fn load_config() -> ServerConfig { - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-in-prod".to_string()); + let jwt_secret = + std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-in-prod".to_string()); let port = std::env::var("PORT") .unwrap_or_else(|_| "8080".to_string()) .parse::() .unwrap_or(8080); - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "sqlite:/gitforge.db".to_string()); + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/gitforge.db".to_string()); ServerConfig { jwt_secret, @@ -105,17 +106,28 @@ pub fn create_shutdown_flag() -> Arc { /// Spawn the shutdown signal handler pub fn spawn_shutdown_handler(shutdown_flag: Arc) { tokio::spawn(async move { - let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap(); - let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()).unwrap(); - - tokio::select! { - _ = sigterm.recv() => { - tracing::info!("received SIGTERM, initiating graceful shutdown..."); - } - _ = sigint.recv() => { - tracing::info!("received SIGINT, initiating graceful shutdown..."); + #[cfg(unix)] + { + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to install SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, initiating graceful shutdown..."); + } + _ = sigint.recv() => { + tracing::info!("received SIGINT, initiating graceful shutdown..."); + } } } + + #[cfg(not(unix))] + if let Err(error) = signal::ctrl_c().await { + tracing::error!(%error, "failed to install console interrupt handler"); + } + shutdown_flag.store(true, Ordering::SeqCst); }); } @@ -140,6 +152,8 @@ pub async fn graceful_shutdown_delay() { #[cfg(test)] mod tests { + use std::sync::{Mutex, OnceLock}; + use super::*; fn clear_env() { @@ -148,9 +162,17 @@ mod tests { std::env::remove_var("DATABASE_URL"); } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + #[test] fn test_load_config_defaults() { // Clear any set env vars first + let _lock = env_lock(); clear_env(); let config = load_config(); @@ -162,6 +184,7 @@ mod tests { #[test] fn test_load_config_from_env() { // Always set fresh values - clear first then set + let _lock = env_lock(); clear_env(); std::env::set_var("JWT_SECRET", "test-secret"); std::env::set_var("PORT", "3000"); diff --git a/services/ci/src/main.rs b/services/ci/src/main.rs index 8e6ae883..1c15b258 100644 --- a/services/ci/src/main.rs +++ b/services/ci/src/main.rs @@ -3,9 +3,11 @@ //! Main entry point for the CI orchestration service. use futures::StreamExt; -use gitforce_ci::{CiEngine, PipelineDefinition, PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition}; +use gitforce_ci::{ + CiEngine, JobDefinition, PipelineDefinition, PipelineTriggerEvent, StepDefinition, TriggerType, +}; use gitforce_events::{ - EventBus, EventEnvelope, EventFilter, EventType, EventPayload, InMemoryEventBus, + EventBus, EventEnvelope, EventFilter, EventPayload, EventType, InMemoryEventBus, }; use gitforce_scheduler::Scheduler; use std::collections::HashMap; @@ -53,8 +55,15 @@ async fn main() -> anyhow::Result<()> { // Start event consumer loop let shutdown_consumer = shutdown.clone(); - let consumer_handle = tokio::spawn(async move { - if let Err(e) = run_event_consumer(event_bus_clone, scheduler_clone, pipeline_cache_clone, shutdown_consumer).await { + let _consumer_handle = tokio::spawn(async move { + if let Err(e) = run_event_consumer( + event_bus_clone, + scheduler_clone, + pipeline_cache_clone, + shutdown_consumer, + ) + .await + { tracing::error!("event consumer error: {}", e); } }); @@ -62,7 +71,7 @@ async fn main() -> anyhow::Result<()> { // Start scheduler loop let scheduler_clone = scheduler.clone(); let shutdown_scheduler = shutdown.clone(); - let scheduler_handle = tokio::spawn(async move { + let _scheduler_handle = tokio::spawn(async move { let mut ticker = tokio::time::interval(Duration::from_secs(5)); loop { if shutdown_scheduler.load(Ordering::SeqCst) { @@ -99,17 +108,28 @@ pub fn create_shutdown_flag() -> Arc { /// Spawn the shutdown signal handler pub fn spawn_shutdown_handler(shutdown_flag: Arc) { tokio::spawn(async move { - let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap(); - let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()).unwrap(); - - tokio::select! { - _ = sigterm.recv() => { - tracing::info!("received SIGTERM, initiating graceful shutdown..."); - } - _ = sigint.recv() => { - tracing::info!("received SIGINT, initiating graceful shutdown..."); + #[cfg(unix)] + { + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to install SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, initiating graceful shutdown..."); + } + _ = sigint.recv() => { + tracing::info!("received SIGINT, initiating graceful shutdown..."); + } } } + + #[cfg(not(unix))] + if let Err(error) = signal::ctrl_c().await { + tracing::error!(%error, "failed to install console interrupt handler"); + } + shutdown_flag.store(true, Ordering::SeqCst); }); } @@ -200,9 +220,10 @@ async fn handle_push_event( // Get or create pipeline definition for this repo let pipeline = { let mut cache = pipeline_cache.lock().unwrap(); - cache.entry(repo_id).or_insert_with(|| { - create_default_pipeline(&repo_id.to_string()) - }).clone() + cache + .entry(repo_id) + .or_insert_with(|| create_default_pipeline(&repo_id.to_string())) + .clone() }; // Create trigger event @@ -211,13 +232,18 @@ async fn handle_push_event( repo_id, payload.new_hash.clone(), TriggerType::Push, - ).with_ref(ref_name.clone()); + ) + .with_ref(ref_name.clone()); // Create and start the CI engine let engine = CiEngine::new(trigger_event, pipeline).await?; engine.start().await?; - tracing::info!("pipeline triggered for repo {} on ref {}", repo_id, ref_name); + tracing::info!( + "pipeline triggered for repo {} on ref {}", + repo_id, + ref_name + ); // Enqueue ready jobs to scheduler let ready_jobs = engine.ready_jobs().await; @@ -271,15 +297,13 @@ fn create_default_pipeline(repo_id: &str) -> PipelineDefinition { image: "rust:latest".to_string(), needs: vec!["build".to_string()], env: HashMap::new(), - steps: vec![ - StepDefinition { - name: "test".to_string(), - run: "cargo test".to_string(), - env: None, - working_directory: None, - condition: None, - }, - ], + steps: vec![StepDefinition { + name: "test".to_string(), + run: "cargo test".to_string(), + env: None, + working_directory: None, + condition: None, + }], timeout: Some("30m".to_string()), retry: Some(1), }, diff --git a/services/git-server/src/main.rs b/services/git-server/src/main.rs index 99b9a327..a1524068 100644 --- a/services/git-server/src/main.rs +++ b/services/git-server/src/main.rs @@ -77,17 +77,28 @@ pub fn create_shutdown_flag() -> Arc { /// Spawn the shutdown signal handler pub fn spawn_shutdown_handler(shutdown_flag: Arc) { tokio::spawn(async move { - let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap(); - let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()).unwrap(); - - tokio::select! { - _ = sigterm.recv() => { - tracing::info!("received SIGTERM, initiating graceful shutdown..."); - } - _ = sigint.recv() => { - tracing::info!("received SIGINT, initiating graceful shutdown..."); + #[cfg(unix)] + { + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to install SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, initiating graceful shutdown..."); + } + _ = sigint.recv() => { + tracing::info!("received SIGINT, initiating graceful shutdown..."); + } } } + + #[cfg(not(unix))] + if let Err(error) = signal::ctrl_c().await { + tracing::error!(%error, "failed to install console interrupt handler"); + } + shutdown_flag.store(true, Ordering::SeqCst); }); } diff --git a/services/runner/src/main.rs b/services/runner/src/main.rs index 9083349b..8c3835c5 100644 --- a/services/runner/src/main.rs +++ b/services/runner/src/main.rs @@ -70,17 +70,28 @@ pub fn create_shutdown_flag() -> Arc { /// Spawn the shutdown signal handler pub fn spawn_shutdown_handler(shutdown_flag: Arc) { tokio::spawn(async move { - let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()).unwrap(); - let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()).unwrap(); - - tokio::select! { - _ = sigterm.recv() => { - tracing::info!("received SIGTERM, initiating graceful shutdown..."); - } - _ = sigint.recv() => { - tracing::info!("received SIGINT, initiating graceful shutdown..."); + #[cfg(unix)] + { + let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to install SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, initiating graceful shutdown..."); + } + _ = sigint.recv() => { + tracing::info!("received SIGINT, initiating graceful shutdown..."); + } } } + + #[cfg(not(unix))] + if let Err(error) = signal::ctrl_c().await { + tracing::error!(%error, "failed to install console interrupt handler"); + } + shutdown_flag.store(true, Ordering::SeqCst); }); } diff --git a/tarpaulin-report.json b/tarpaulin-report.json deleted file mode 100644 index 81981052..00000000 --- a/tarpaulin-report.json +++ /dev/null @@ -1 +0,0 @@ -{"files":[{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","auth.rs"],"content":"//! API authentication\n\nuse chrono::Utc;\nuse gitforce_common::{Error, UserId};\nuse jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};\nuse serde::{Deserialize, Serialize};\n\n/// JWT claims for API authentication\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Claims {\n /// Subject (user ID)\n pub sub: String,\n /// User ID\n pub user_id: UserId,\n /// Username\n pub username: String,\n /// Role\n pub role: String,\n /// Expiration time (as UTC timestamp)\n pub exp: i64,\n /// Issued at\n pub iat: i64,\n}\n\nimpl Claims {\n /// Create new claims\n pub fn new(user_id: UserId, username: &str, role: &str, expiry_hours: i64) -> Self {\n let now = Utc::now();\n Self {\n sub: user_id.to_string(),\n user_id,\n username: username.to_string(),\n role: role.to_string(),\n exp: now.timestamp() + (expiry_hours * 3600),\n iat: now.timestamp(),\n }\n }\n\n /// Check if the token is expired\n pub fn is_expired(&self) -> bool {\n Utc::now().timestamp() > self.exp\n }\n}\n\n/// API authentication handler\n#[derive(Clone)]\npub struct ApiAuth {\n #[allow(dead_code)]\n jwt_secret: String,\n encoding_key: EncodingKey,\n decoding_key: DecodingKey,\n}\n\nimpl ApiAuth {\n /// Create a new API authenticator\n pub fn new(jwt_secret: &str) -> Self {\n let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes());\n let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes());\n\n Self {\n jwt_secret: jwt_secret.to_string(),\n encoding_key,\n decoding_key,\n }\n }\n\n /// Generate a JWT token for a user\n pub fn generate_token(&self, user_id: UserId, username: &str, role: &str) -> Result {\n let claims = Claims::new(user_id, username, role, 24); // 24 hour expiry\n\n let token = encode(&Header::default(), &claims, &self.encoding_key)\n .map_err(|e| Error::auth(format!(\"failed to generate token: {}\", e)))?;\n\n Ok(token)\n }\n\n /// Validate and decode a JWT token\n pub fn validate_token(&self, token: &str) -> Result {\n let token_data = decode::(token, &self.decoding_key, &Validation::default())\n .map_err(|e| Error::auth(format!(\"invalid token: {}\", e)))?;\n\n let claims = token_data.claims;\n\n if claims.is_expired() {\n return Err(Error::auth(\"token expired\".to_string()));\n }\n\n Ok(claims)\n }\n\n /// Extract token from Authorization header\n pub fn extract_token(auth_header: &str) -> Option<&str> {\n auth_header.strip_prefix(\"Bearer \")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_claims_creation() {\n let user_id = UserId::new();\n let claims = Claims::new(user_id, \"user1\", \"developer\", 2);\n assert_eq!(claims.username, \"user1\");\n assert_eq!(claims.role, \"developer\");\n }\n\n #[test]\n fn test_claims_expiry() {\n let claims = Claims::new(UserId::new(), \"test\", \"admin\", 1);\n assert!(!claims.is_expired());\n\n let expired_claims = Claims {\n exp: Utc::now().timestamp() - 3600,\n iat: Utc::now().timestamp() - 7200,\n ..claims\n };\n assert!(expired_claims.is_expired());\n }\n\n #[test]\n fn test_token_generation_and_validation() {\n let auth = ApiAuth::new(\"test-secret\");\n let user_id = UserId::new();\n\n let token = auth.generate_token(user_id, \"testuser\", \"admin\").unwrap();\n assert!(!token.is_empty());\n\n let claims = auth.validate_token(&token).unwrap();\n assert_eq!(claims.username, \"testuser\");\n assert_eq!(claims.role, \"admin\");\n }\n\n #[test]\n fn test_extract_token() {\n assert_eq!(ApiAuth::extract_token(\"Bearer abc123\"), Some(\"abc123\"));\n assert_eq!(ApiAuth::extract_token(\"abc123\"), None);\n assert_eq!(ApiAuth::extract_token(\"Basic abc\"), None);\n }\n\n #[test]\n fn test_invalid_token() {\n let auth = ApiAuth::new(\"test-secret\");\n let result = auth.validate_token(\"invalid.token.here\");\n assert!(result.is_err());\n }\n\n #[test]\n fn test_wrong_secret_validation() {\n let auth1 = ApiAuth::new(\"secret1\");\n let auth2 = ApiAuth::new(\"secret2\");\n let user_id = UserId::new();\n let token = auth1.generate_token(user_id, \"testuser\", \"admin\").unwrap();\n let result = auth2.validate_token(&token);\n assert!(result.is_err());\n }\n\n #[test]\n fn test_claims_with_different_roles() {\n let user_id = UserId::new();\n let admin_claims = Claims::new(user_id, \"admin\", \"admin\", 24);\n let user_claims = Claims::new(user_id, \"user\", \"user\", 24);\n assert_eq!(admin_claims.role, \"admin\");\n assert_eq!(user_claims.role, \"user\");\n }\n\n #[test]\n fn test_claims_debug() {\n let claims = Claims::new(UserId::new(), \"test\", \"admin\", 1);\n let debug_str = format!(\"{:?}\", claims);\n assert!(debug_str.contains(\"test\"));\n }\n\n #[test]\n fn test_token_with_long_expiry() {\n let auth = ApiAuth::new(\"test-secret\");\n let user_id = UserId::new();\n let token = auth.generate_token(user_id, \"user\", \"admin\").unwrap();\n let claims = auth.validate_token(&token).unwrap();\n // Token should be valid and not expired\n assert!(!claims.is_expired());\n }\n\n #[test]\n fn test_claims_iat_and_exp() {\n let before = Utc::now().timestamp();\n let claims = Claims::new(UserId::new(), \"test\", \"admin\", 2);\n let after = Utc::now().timestamp();\n // iat should be current time\n assert!(claims.iat >= before);\n assert!(claims.iat <= after);\n // exp should be iat + 2 hours\n assert_eq!(claims.exp, claims.iat + (2 * 3600));\n }\n}","traces":[{"line":27,"address":[13219360,13219945],"length":1,"stats":{"Line":15}},{"line":28,"address":[11272199],"length":1,"stats":{"Line":15}},{"line":30,"address":[11272218],"length":1,"stats":{"Line":15}},{"line":32,"address":[11272242],"length":1,"stats":{"Line":18}},{"line":33,"address":[11272312],"length":1,"stats":{"Line":21}},{"line":34,"address":[15669812,15669879,15669963],"length":1,"stats":{"Line":31}},{"line":35,"address":[15669943],"length":1,"stats":{"Line":10}},{"line":40,"address":[15669488],"length":1,"stats":{"Line":1}},{"line":41,"address":[15669502],"length":1,"stats":{"Line":1}},{"line":56,"address":[15672524,15672080,15672518],"length":1,"stats":{"Line":5}},{"line":57,"address":[15672138],"length":1,"stats":{"Line":5}},{"line":58,"address":[15672170,15672238],"length":1,"stats":{"Line":10}},{"line":61,"address":[15672264],"length":1,"stats":{"Line":5}},{"line":68,"address":[15670208,15671018,15670980],"length":1,"stats":{"Line":13}},{"line":69,"address":[13220125],"length":1,"stats":{"Line":13}},{"line":71,"address":[11273073,11272977,11273190,11272916],"length":1,"stats":{"Line":37}},{"line":72,"address":[14530544,14530571],"length":1,"stats":{"Line":5}},{"line":74,"address":[11273395],"length":1,"stats":{"Line":16}},{"line":78,"address":[15672010,15671040,15672026],"length":1,"stats":{"Line":1}},{"line":79,"address":[11273619,11273873,11273776],"length":1,"stats":{"Line":3}},{"line":80,"address":[15672055,15671169,15671313,15671513],"length":1,"stats":{"Line":6}},{"line":82,"address":[11274084],"length":1,"stats":{"Line":2}},{"line":84,"address":[11274203,11274258],"length":1,"stats":{"Line":4}},{"line":85,"address":[11274398,11274338],"length":1,"stats":{"Line":0}},{"line":88,"address":[11274264],"length":1,"stats":{"Line":2}},{"line":92,"address":[11272720],"length":1,"stats":{"Line":1}},{"line":93,"address":[11272734],"length":1,"stats":{"Line":1}}],"covered":26,"coverable":27},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","dashboard.rs"],"content":"//! Web Dashboard for GitForge\n//!\n//! Simple HTML dashboard served at the root path.\n\nuse axum::{\n response::{Html, IntoResponse},\n routing::get,\n Router,\n};\n\n/// Dashboard HTML\nconst DASHBOARD_HTML: &str = r#\"\n\n\n\n \n \n GitForge Dashboard\n \n\n\n
\n
\n

🚀 GitForge

\n

Self-hosted Git platform with CI/CD capabilities

\n \n
\n\n
\n
\n

📊 System Status

\n
\n API Server\n Online\n
\n
\n Database\n Connected\n
\n
\n Version\n 0.2.0\n
\n
\n\n
\n

🔧 Services

\n
\n Git Server\n Port 2222/8082\n
\n
\n CI Orchestrator\n Running\n
\n
\n Runner Agents\n 0 online\n
\n
\n\n
\n

📦 Resources

\n
\n Repositories\n 0\n
\n
\n Pipelines\n 0\n
\n
\n Artifacts\n 0\n
\n
\n\n
\n

⚡ CI/CD

\n
\n Pipeline Runs (24h)\n 0\n
\n
\n Success Rate\n --%\n
\n
\n Avg Duration\n --\n
\n
\n
\n\n
\n

🔌 API Endpoints

\n
\n GET /health - Health check\n
\n
\n GET /metrics - Prometheus metrics\n
\n
\n GET /api/repos - List repositories\n
\n
\n POST /api/repos - Create repository\n
\n
\n GET /api/pipelines - List pipelines\n
\n
\n GET /api/runners - List runners\n
\n
\n GET /api/artifacts - List artifacts\n
\n

\n Full API documentation available at /swagger-ui\n

\n
\n\n \n
\n\n\n\"#;\n\n/// Dashboard handler\npub async fn dashboard() -> impl IntoResponse {\n Html(DASHBOARD_HTML)\n}\n\n/// Create dashboard routes\npub fn dashboard_routes() -> Router {\n Router::new()\n .route(\"/dashboard\", get(dashboard))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_dashboard_returns_html() {\n let response = dashboard().await.into_response();\n assert_eq!(response.status(), 200);\n }\n}\n","traces":[{"line":172,"address":[11706754,11706796,11706736],"length":1,"stats":{"Line":6}},{"line":177,"address":[12359077,12358928,12359102],"length":1,"stats":{"Line":5}},{"line":178,"address":[12359007,12358935],"length":1,"stats":{"Line":27}},{"line":179,"address":[12359095,12358966,12358973,12359020],"length":1,"stats":{"Line":12}}],"covered":4,"coverable":4},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","lib.rs"],"content":"//! GitForge API\n//!\n//! REST API gateway for GitForge.\n//!\n//! ## Features\n//!\n//! - **OpenAPI/Swagger UI**: Interactive API documentation at `/swagger-ui`\n//! - **Prometheus Metrics**: Observability at `/metrics`\n//! - **RESTful Endpoints**: Repos, CI, Runners, Artifacts\n\npub mod auth;\npub mod dashboard;\npub mod metrics;\npub mod metrics_middleware;\npub mod middleware;\npub mod openapi;\npub mod routes;\npub mod server;\n\npub use auth::{ApiAuth, Claims};\npub use metrics::Metrics;\npub use server::ApiServer;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","metrics.rs"],"content":"//! Prometheus metrics for GitForge API\n//!\n//! Provides observability into API operations, CI/CD pipelines, and runner status.\n\nuse prometheus::{\n Counter, Histogram, HistogramOpts, HistogramVec, IntCounterVec, IntGauge, Opts, Registry,\n};\n\n/// GitForge metrics collector\npub struct Metrics {\n pub registry: Registry,\n\n // HTTP metrics\n pub http_requests_total: IntCounterVec,\n pub http_request_duration_seconds: HistogramVec,\n\n // Repository metrics\n pub repos_total: IntGauge,\n pub repo_operations_total: IntCounterVec,\n\n // CI/CD metrics\n pub pipelines_total: IntGauge,\n pub pipeline_runs_total: IntCounterVec,\n pub jobs_total: IntGauge,\n pub job_duration_seconds: Histogram,\n\n // Runner metrics\n pub runners_online: IntGauge,\n pub runners_busy: IntGauge,\n pub runners_offline: IntGauge,\n pub job_assignments_total: Counter,\n\n // Event metrics\n pub events_published_total: IntCounterVec,\n pub events_consumed_total: IntCounterVec,\n\n // Storage metrics\n pub artifacts_total: IntGauge,\n pub artifact_size_bytes: Histogram,\n}\n\nimpl Metrics {\n /// Create a new metrics collector\n pub fn new() -> Self {\n let registry = Registry::new();\n\n // HTTP metrics\n let http_requests_total = IntCounterVec::new(\n Opts::new(\"http_requests_total\", \"Total HTTP requests\"),\n &[\"method\", \"endpoint\", \"status\"],\n )\n .expect(\"failed to create http_requests_total counter\");\n registry\n .register(Box::new(http_requests_total.clone()))\n .expect(\"failed to register http_requests_total\");\n\n let http_request_duration_seconds = HistogramVec::new(\n HistogramOpts::new(\n \"http_request_duration_seconds\",\n \"HTTP request duration in seconds\",\n )\n .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),\n &[\"method\", \"endpoint\"],\n )\n .expect(\"failed to create http_request_duration_seconds histogram\");\n registry\n .register(Box::new(http_request_duration_seconds.clone()))\n .expect(\"failed to register http_request_duration_seconds\");\n\n // Repository metrics\n let repos_total = IntGauge::new(\"gitforge_repos_total\", \"Total number of repositories\")\n .expect(\"failed to create repos_total gauge\");\n registry\n .register(Box::new(repos_total.clone()))\n .expect(\"failed to register repos_total\");\n\n let repo_operations_total = IntCounterVec::new(\n Opts::new(\"gitforge_repo_operations_total\", \"Total repository operations\"),\n &[\"operation\"],\n )\n .expect(\"failed to create repo_operations_total counter\");\n registry\n .register(Box::new(repo_operations_total.clone()))\n .expect(\"failed to register repo_operations_total\");\n\n // CI/CD metrics\n let pipelines_total = IntGauge::new(\"gitforge_pipelines_total\", \"Total number of pipelines\")\n .expect(\"failed to create pipelines_total gauge\");\n registry\n .register(Box::new(pipelines_total.clone()))\n .expect(\"failed to register pipelines_total\");\n\n let pipeline_runs_total = IntCounterVec::new(\n Opts::new(\"gitforge_pipeline_runs_total\", \"Total pipeline runs\"),\n &[\"status\"],\n )\n .expect(\"failed to create pipeline_runs_total counter\");\n registry\n .register(Box::new(pipeline_runs_total.clone()))\n .expect(\"failed to register pipeline_runs_total\");\n\n let jobs_total = IntGauge::new(\"gitforge_jobs_total\", \"Total number of jobs\")\n .expect(\"failed to create jobs_total gauge\");\n registry\n .register(Box::new(jobs_total.clone()))\n .expect(\"failed to register jobs_total\");\n\n let job_duration_seconds = Histogram::with_opts(\n HistogramOpts::new(\"gitforge_job_duration_seconds\", \"Job duration in seconds\")\n .buckets(vec![1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0]),\n )\n .expect(\"failed to create job_duration_seconds histogram\");\n registry\n .register(Box::new(job_duration_seconds.clone()))\n .expect(\"failed to register job_duration_seconds\");\n\n // Runner metrics\n let runners_online = IntGauge::new(\"gitforge_runners_online\", \"Number of online runners\")\n .expect(\"failed to create runners_online gauge\");\n registry\n .register(Box::new(runners_online.clone()))\n .expect(\"failed to register runners_online\");\n\n let runners_busy = IntGauge::new(\"gitforge_runners_busy\", \"Number of busy runners\")\n .expect(\"failed to create runners_busy gauge\");\n registry\n .register(Box::new(runners_busy.clone()))\n .expect(\"failed to register runners_busy\");\n\n let runners_offline = IntGauge::new(\"gitforge_runners_offline\", \"Number of offline runners\")\n .expect(\"failed to create runners_offline gauge\");\n registry\n .register(Box::new(runners_offline.clone()))\n .expect(\"failed to register runners_offline\");\n\n let job_assignments_total =\n Counter::new(\"gitforge_job_assignments_total\", \"Total job assignments\")\n .expect(\"failed to create job_assignments_total counter\");\n registry\n .register(Box::new(job_assignments_total.clone()))\n .expect(\"failed to register job_assignments_total\");\n\n // Event metrics\n let events_published_total = IntCounterVec::new(\n Opts::new(\"gitforge_events_published_total\", \"Total events published\"),\n &[\"event_type\"],\n )\n .expect(\"failed to create events_published_total counter\");\n registry\n .register(Box::new(events_published_total.clone()))\n .expect(\"failed to register events_published_total\");\n\n let events_consumed_total = IntCounterVec::new(\n Opts::new(\"gitforge_events_consumed_total\", \"Total events consumed\"),\n &[\"event_type\"],\n )\n .expect(\"failed to create events_consumed_total counter\");\n registry\n .register(Box::new(events_consumed_total.clone()))\n .expect(\"failed to register events_consumed_total\");\n\n // Storage metrics\n let artifacts_total = IntGauge::new(\"gitforge_artifacts_total\", \"Total number of artifacts\")\n .expect(\"failed to create artifacts_total gauge\");\n registry\n .register(Box::new(artifacts_total.clone()))\n .expect(\"failed to register artifacts_total\");\n\n let artifact_size_bytes = Histogram::with_opts(\n HistogramOpts::new(\"gitforge_artifact_size_bytes\", \"Artifact size in bytes\")\n .buckets(vec![1024.0, 10240.0, 102400.0, 1048576.0, 10485760.0, 104857600.0]),\n )\n .expect(\"failed to create artifact_size_bytes histogram\");\n registry\n .register(Box::new(artifact_size_bytes.clone()))\n .expect(\"failed to register artifact_size_bytes\");\n\n Self {\n registry,\n http_requests_total,\n http_request_duration_seconds,\n repos_total,\n repo_operations_total,\n pipelines_total,\n pipeline_runs_total,\n jobs_total,\n job_duration_seconds,\n runners_online,\n runners_busy,\n runners_offline,\n job_assignments_total,\n events_published_total,\n events_consumed_total,\n artifacts_total,\n artifact_size_bytes,\n }\n }\n\n /// Increment HTTP request counter\n pub fn record_http_request(&self, method: &str, endpoint: &str, status: u16) {\n self.http_requests_total\n .with_label_values(&[method, endpoint, &status.to_string()])\n .inc();\n }\n\n /// Record HTTP request duration\n pub fn record_http_duration(&self, method: &str, endpoint: &str, duration_secs: f64) {\n self.http_request_duration_seconds\n .with_label_values(&[method, endpoint])\n .observe(duration_secs);\n }\n\n /// Increment repository operation counter\n pub fn record_repo_operation(&self, operation: &str) {\n self.repo_operations_total\n .with_label_values(&[operation])\n .inc();\n }\n\n /// Increment pipeline run counter\n pub fn record_pipeline_run(&self, status: &str) {\n self.pipeline_runs_total\n .with_label_values(&[status])\n .inc();\n }\n\n /// Record job duration\n pub fn record_job_duration(&self, duration_secs: f64) {\n self.job_duration_seconds.observe(duration_secs);\n }\n\n /// Record job assignment\n pub fn record_job_assignment(&self) {\n self.job_assignments_total.inc();\n }\n\n /// Increment event published counter\n pub fn record_event_published(&self, event_type: &str) {\n self.events_published_total\n .with_label_values(&[event_type])\n .inc();\n }\n\n /// Increment event consumed counter\n pub fn record_event_consumed(&self, event_type: &str) {\n self.events_consumed_total\n .with_label_values(&[event_type])\n .inc();\n }\n\n /// Record artifact size\n pub fn record_artifact_size(&self, size_bytes: u64) {\n self.artifact_size_bytes.observe(size_bytes as f64);\n }\n}\n\nimpl Default for Metrics {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_metrics_creation() {\n let metrics = Metrics::new();\n // Verify metrics were created by recording and gathering\n metrics.record_http_request(\"GET\", \"/health\", 200);\n let families = metrics.registry.gather();\n assert!(!families.is_empty());\n }\n\n #[test]\n fn test_record_http_request() {\n let metrics = Metrics::new();\n metrics.record_http_request(\"GET\", \"/health\", 200);\n metrics.record_http_request(\"POST\", \"/repos\", 201);\n }\n\n #[test]\n fn test_record_repo_operation() {\n let metrics = Metrics::new();\n metrics.record_repo_operation(\"create\");\n metrics.record_repo_operation(\"delete\");\n }\n\n #[test]\n fn test_record_pipeline_run() {\n let metrics = Metrics::new();\n metrics.record_pipeline_run(\"succeeded\");\n metrics.record_pipeline_run(\"failed\");\n }\n\n #[test]\n fn test_record_job_duration() {\n let metrics = Metrics::new();\n metrics.record_job_duration(5.0);\n metrics.record_job_duration(120.5);\n }\n\n #[test]\n fn test_record_event() {\n let metrics = Metrics::new();\n metrics.record_event_published(\"push.received\");\n metrics.record_event_consumed(\"push.received\");\n }\n\n #[test]\n fn test_record_artifact_size() {\n let metrics = Metrics::new();\n metrics.record_artifact_size(1024);\n metrics.record_artifact_size(10485760);\n }\n\n #[test]\n fn test_record_http_duration() {\n let metrics = Metrics::new();\n metrics.record_http_duration(\"GET\", \"/api/repos\", 0.05);\n metrics.record_http_duration(\"POST\", \"/api/repos\", 0.123);\n }\n\n #[test]\n fn test_record_job_assignment() {\n let metrics = Metrics::new();\n metrics.record_job_assignment();\n metrics.record_job_assignment();\n }\n\n #[test]\n fn test_runners_gauges() {\n let metrics = Metrics::new();\n // Test that we can access the runner gauges\n metrics.runners_online.inc();\n metrics.runners_busy.inc();\n metrics.runners_offline.inc();\n metrics.repos_total.set(10);\n metrics.pipelines_total.set(5);\n metrics.jobs_total.set(20);\n metrics.artifacts_total.set(100);\n }\n}\n","traces":[{"line":44,"address":[15096271,15096199,15090016],"length":1,"stats":{"Line":6}},{"line":45,"address":[12189665],"length":1,"stats":{"Line":6}},{"line":49,"address":[12189706],"length":1,"stats":{"Line":6}},{"line":54,"address":[12638788,12638709],"length":1,"stats":{"Line":15}},{"line":58,"address":[15090832,15090455],"length":1,"stats":{"Line":16}},{"line":62,"address":[15090499,15090526,15090840,15096249],"length":1,"stats":{"Line":16}},{"line":67,"address":[12190556,12190635],"length":1,"stats":{"Line":16}},{"line":71,"address":[12190726,12190816],"length":1,"stats":{"Line":16}},{"line":74,"address":[12639700,12639779],"length":1,"stats":{"Line":22}},{"line":78,"address":[12639878],"length":1,"stats":{"Line":13}},{"line":83,"address":[12640023,12640102],"length":1,"stats":{"Line":30}},{"line":87,"address":[12191371,12191281],"length":1,"stats":{"Line":27}},{"line":90,"address":[12191450,12191387],"length":1,"stats":{"Line":31}},{"line":94,"address":[12640485],"length":1,"stats":{"Line":17}},{"line":99,"address":[12191745,12191682],"length":1,"stats":{"Line":32}},{"line":102,"address":[12191836,12191926],"length":1,"stats":{"Line":31}},{"line":105,"address":[15092561,15092482],"length":1,"stats":{"Line":33}},{"line":109,"address":[15093009,15092660],"length":1,"stats":{"Line":35}},{"line":110,"address":[15092704,15093017,15096227,15092731],"length":1,"stats":{"Line":36}},{"line":114,"address":[12192644,12192577],"length":1,"stats":{"Line":36}},{"line":118,"address":[12192735,12192825],"length":1,"stats":{"Line":36}},{"line":121,"address":[15093508,15093429],"length":1,"stats":{"Line":36}},{"line":124,"address":[12642137,12642039],"length":1,"stats":{"Line":36}},{"line":127,"address":[12642145,12642224],"length":1,"stats":{"Line":37}},{"line":130,"address":[15093891,15093989],"length":1,"stats":{"Line":29}},{"line":133,"address":[12642508,12642429],"length":1,"stats":{"Line":19}},{"line":136,"address":[12642607,12642705],"length":1,"stats":{"Line":44}},{"line":140,"address":[12642792,12642713],"length":1,"stats":{"Line":44}},{"line":145,"address":[15094459],"length":1,"stats":{"Line":22}},{"line":150,"address":[15094604,15094677],"length":1,"stats":{"Line":44}},{"line":154,"address":[12194058],"length":1,"stats":{"Line":11}},{"line":159,"address":[12643341,12643414],"length":1,"stats":{"Line":30}},{"line":163,"address":[12194335,12194419],"length":1,"stats":{"Line":30}},{"line":166,"address":[12194492,12194435],"length":1,"stats":{"Line":30}},{"line":170,"address":[12643773,12644065],"length":1,"stats":{"Line":35}},{"line":171,"address":[15095409,15096205,15095385,15095641],"length":1,"stats":{"Line":19}},{"line":175,"address":[12644207,12644268],"length":1,"stats":{"Line":30}},{"line":200,"address":[12637152,12637527,12637533],"length":1,"stats":{"Line":4}},{"line":201,"address":[12188699,12188508],"length":1,"stats":{"Line":15}},{"line":202,"address":[15088805,15088933],"length":1,"stats":{"Line":14}},{"line":207,"address":[15089376,15089535,15089541],"length":1,"stats":{"Line":3}},{"line":208,"address":[12189097],"length":1,"stats":{"Line":10}},{"line":209,"address":[15089421],"length":1,"stats":{"Line":3}},{"line":210,"address":[15089471],"length":1,"stats":{"Line":10}},{"line":214,"address":[12638281,12638287,12638160],"length":1,"stats":{"Line":1}},{"line":215,"address":[12189395],"length":1,"stats":{"Line":1}},{"line":216,"address":[12189399],"length":1,"stats":{"Line":1}},{"line":221,"address":[15089152,15089273,15089279],"length":1,"stats":{"Line":1}},{"line":222,"address":[15089171],"length":1,"stats":{"Line":1}},{"line":223,"address":[12188871],"length":1,"stats":{"Line":1}},{"line":228,"address":[15089120],"length":1,"stats":{"Line":1}},{"line":229,"address":[15089135],"length":1,"stats":{"Line":1}},{"line":233,"address":[15089696],"length":1,"stats":{"Line":1}},{"line":234,"address":[12638133],"length":1,"stats":{"Line":1}},{"line":238,"address":[15089999,15089872,15089993],"length":1,"stats":{"Line":1}},{"line":239,"address":[15089891],"length":1,"stats":{"Line":1}},{"line":240,"address":[15089895],"length":1,"stats":{"Line":1}},{"line":245,"address":[12189340,12189346,12189232],"length":1,"stats":{"Line":1}},{"line":246,"address":[15089571],"length":1,"stats":{"Line":1}},{"line":247,"address":[12189255],"length":1,"stats":{"Line":1}},{"line":252,"address":[12188976],"length":1,"stats":{"Line":1}},{"line":253,"address":[12188990],"length":1,"stats":{"Line":1}},{"line":258,"address":[12195504],"length":1,"stats":{"Line":0}},{"line":259,"address":[12195512],"length":1,"stats":{"Line":0}}],"covered":62,"coverable":64},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","metrics_middleware.rs"],"content":"//! HTTP metrics middleware\n//!\n//! Intercepts all HTTP requests and records metrics for Prometheus export.\n\nuse crate::metrics::Metrics;\nuse axum::{\n extract::Request,\n response::Response,\n};\nuse std::{\n convert::Infallible,\n future::Future,\n pin::Pin,\n sync::Arc,\n task::{Context, Poll},\n};\nuse tower::{Layer, Service};\n\n/// Middleware service that records HTTP metrics\n#[derive(Clone)]\npub struct MetricsMiddleware {\n inner: S,\n metrics: Arc,\n}\n\nimpl MetricsMiddleware {\n /// Create a new metrics middleware\n pub fn new(inner: S, metrics: Arc) -> Self {\n Self { inner, metrics }\n }\n}\n\nimpl Service for MetricsMiddleware\nwhere\n S: Service + Clone + Send + 'static,\n S::Future: Send,\n{\n type Response = Response;\n type Error = Infallible;\n type Future = Pin> + Send>>;\n\n fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> {\n // Metrics middleware is always ready\n Poll::Ready(Ok(()))\n }\n\n fn call(&mut self, request: Request) -> Self::Future {\n let metrics = self.metrics.clone();\n let mut inner = self.inner.clone();\n\n // Normalize the path for metrics (replace UUIDs with {id})\n let path = normalize_path(request.uri().path());\n\n // Record start time\n let start = std::time::Instant::now();\n let method = request.method().as_str().to_string();\n\n // We need to use a separate future that captures method and path\n Box::pin(async move {\n let response = inner.call(request).await;\n\n // Record metrics\n let duration = start.elapsed().as_secs_f64();\n // Handle case where inner service returns error (shouldn't happen with Infallible)\n let status = response.as_ref().map(|r| r.status()).unwrap_or_default();\n\n metrics.record_http_request(&method, &path, status.as_u16());\n metrics.record_http_duration(&method, &path, duration);\n\n response\n })\n }\n}\n\n/// Layer for creating MetricsMiddleware\n#[derive(Clone)]\npub struct MetricsLayer {\n metrics: Arc,\n}\n\nimpl MetricsLayer {\n /// Create a new metrics layer\n pub fn new(metrics: Arc) -> Self {\n Self { metrics }\n }\n}\n\nimpl Layer for MetricsLayer {\n type Service = MetricsMiddleware;\n\n fn layer(&self, inner: S) -> Self::Service {\n MetricsMiddleware::new(inner, self.metrics.clone())\n }\n}\n\n/// Normalize a path by replacing UUIDs and numeric IDs with placeholders.\n///\n/// This prevents high-cardinality labels in metrics.\n/// Example: `/api/repos/550e8400-e29b-41d4-a716-446655440000` -> `/api/repos/{id}`\n/// Example: `/api/repos/123` -> `/api/repos/{id}`\nfn normalize_path(path: &str) -> String {\n let segments: Vec<&str> = path.split('/').collect();\n let normalized: Vec = segments\n .iter()\n .skip(1) // Skip empty string before leading '/'\n .map(|segment| {\n if is_id_segment(segment) {\n \"{id}\".to_string()\n } else {\n segment.to_string()\n }\n })\n .collect();\n format!(\"/{}\", normalized.join(\"/\"))\n}\n\n/// Check if a path segment looks like an ID (UUID or numeric)\nfn is_id_segment(segment: &str) -> bool {\n // Check for UUID pattern\n if is_uuid(segment) {\n return true;\n }\n\n // Check for pure numeric ID\n if segment.chars().all(|c| c.is_ascii_digit()) {\n return true;\n }\n\n false\n}\n\n/// Check if a string is a UUID\nfn is_uuid(s: &str) -> bool {\n // UUID format: 8-4-4-4-12 hex digits\n let parts: Vec<&str> = s.split('-').collect();\n if parts.len() != 5 {\n return false;\n }\n\n let lengths = [8, 4, 4, 4, 12];\n for (part, &len) in parts.iter().zip(lengths.iter()) {\n if part.len() != len || !part.chars().all(|c| c.is_ascii_hexdigit()) {\n return false;\n }\n }\n true\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_path_with_uuid() {\n assert_eq!(\n normalize_path(\"/api/repos/550e8400-e29b-41d4-a716-446655440000\"),\n \"/api/repos/{id}\"\n );\n }\n\n #[test]\n fn test_normalize_path_with_numeric_id() {\n assert_eq!(\n normalize_path(\"/api/repos/12345\"),\n \"/api/repos/{id}\"\n );\n }\n\n #[test]\n fn test_normalize_path_preserves_static_segments() {\n assert_eq!(\n normalize_path(\"/api/repos\"),\n \"/api/repos\"\n );\n }\n\n #[test]\n fn test_normalize_path_nested_uuid() {\n assert_eq!(\n normalize_path(\"/api/repos/550e8400-e29b-41d4-a716-446655440000/artifacts\"),\n \"/api/repos/{id}/artifacts\"\n );\n }\n\n #[test]\n fn test_is_uuid_valid() {\n assert!(is_uuid(\"550e8400-e29b-41d4-a716-446655440000\"));\n assert!(is_uuid(\"00000000-0000-0000-0000-000000000000\"));\n }\n\n #[test]\n fn test_is_uuid_invalid() {\n assert!(!is_uuid(\"not-a-uuid\"));\n assert!(!is_uuid(\"550e8400-e29b-41d4-a716\")); // too short\n assert!(!is_uuid(\"550e8400-e29b-41d4-a716-4466554400001\")); // too long\n assert!(!is_uuid(\"550e8400-e29b-41d4g-a716-446655440000\")); // non-hex char\n }\n\n #[test]\n fn test_is_id_segment() {\n assert!(is_id_segment(\"123\"));\n assert!(is_id_segment(\"550e8400-e29b-41d4-a716-446655440000\"));\n assert!(!is_id_segment(\"repos\"));\n assert!(!is_id_segment(\"health\"));\n }\n}\n","traces":[{"line":28,"address":[15401888],"length":1,"stats":{"Line":14}},{"line":42,"address":[],"length":0,"stats":{"Line":1}},{"line":44,"address":[],"length":0,"stats":{"Line":0}},{"line":47,"address":[],"length":0,"stats":{"Line":9}},{"line":48,"address":[12950481,12950548],"length":1,"stats":{"Line":10}},{"line":49,"address":[11737677,11737750],"length":1,"stats":{"Line":11}},{"line":52,"address":[12950642,12950701],"length":1,"stats":{"Line":11}},{"line":55,"address":[],"length":0,"stats":{"Line":5}},{"line":56,"address":[15402427],"length":1,"stats":{"Line":4}},{"line":59,"address":[15402009,15402864,15402911,15404828,15403050,15402499,15403235,15404729],"length":1,"stats":{"Line":8}},{"line":60,"address":[12951512,12951701,12951400,12951573],"length":1,"stats":{"Line":7}},{"line":63,"address":[12952235,12952143],"length":1,"stats":{"Line":11}},{"line":65,"address":[15404928,15403860,15404937],"length":1,"stats":{"Line":20}},{"line":67,"address":[11739493],"length":1,"stats":{"Line":9}},{"line":68,"address":[11739697],"length":1,"stats":{"Line":9}},{"line":70,"address":[15404353],"length":1,"stats":{"Line":2}},{"line":83,"address":[15041472],"length":1,"stats":{"Line":8}},{"line":91,"address":[11740744,11740770,11740608],"length":1,"stats":{"Line":11}},{"line":92,"address":[11740754,11740638],"length":1,"stats":{"Line":6}},{"line":101,"address":[11649344,11649965,11649959],"length":1,"stats":{"Line":10}},{"line":102,"address":[12114897],"length":1,"stats":{"Line":3}},{"line":103,"address":[15041689],"length":1,"stats":{"Line":10}},{"line":106,"address":[11649572],"length":1,"stats":{"Line":13}},{"line":107,"address":[12950252],"length":1,"stats":{"Line":4}},{"line":108,"address":[11737410],"length":1,"stats":{"Line":6}},{"line":110,"address":[15401845],"length":1,"stats":{"Line":5}},{"line":114,"address":[15041953,15041870],"length":1,"stats":{"Line":12}},{"line":118,"address":[12114752],"length":1,"stats":{"Line":10}},{"line":120,"address":[12114775],"length":1,"stats":{"Line":3}},{"line":121,"address":[15041561],"length":1,"stats":{"Line":2}},{"line":125,"address":[12114793],"length":1,"stats":{"Line":16}},{"line":126,"address":[11649318],"length":1,"stats":{"Line":1}},{"line":129,"address":[11649311],"length":1,"stats":{"Line":12}},{"line":133,"address":[15043043,15042256,15043037],"length":1,"stats":{"Line":10}},{"line":135,"address":[12115549],"length":1,"stats":{"Line":4}},{"line":136,"address":[11650155,11650084],"length":1,"stats":{"Line":17}},{"line":137,"address":[11650250],"length":1,"stats":{"Line":11}},{"line":140,"address":[11650161],"length":1,"stats":{"Line":6}},{"line":141,"address":[15042493,15042545],"length":1,"stats":{"Line":10}},{"line":142,"address":[11650555,11650677,11650623],"length":1,"stats":{"Line":28}},{"line":143,"address":[11650657],"length":1,"stats":{"Line":1}},{"line":146,"address":[11650574],"length":1,"stats":{"Line":9}}],"covered":41,"coverable":42},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","middleware.rs"],"content":"//! Authentication middleware\n\nuse crate::auth::Claims;\nuse axum::{\n extract::Request,\n http::StatusCode,\n middleware::Next,\n response::{IntoResponse, Response},\n Json,\n};\nuse std::sync::Arc;\n\n/// Paths that don't require authentication\nconst PUBLIC_PATHS: &[&str] = &[\n \"/health\",\n \"/metrics\",\n \"/swagger-ui\",\n \"/api-docs\",\n \"/docs\",\n];\n\n/// Error response for auth failures\n#[derive(Debug, serde::Serialize)]\npub struct AuthErrorResponse {\n pub error: String,\n pub message: String,\n}\n\n/// Authentication middleware\npub async fn auth_middleware(\n auth: Arc,\n request: Request,\n next: Next,\n) -> Response {\n let path = request.uri().path().to_string();\n\n // Skip auth for public paths\n for public_path in PUBLIC_PATHS {\n if path.starts_with(public_path) {\n return next.run(request).await;\n }\n }\n\n // Extract Authorization header\n let auth_header = request\n .headers()\n .get(\"Authorization\")\n .and_then(|v| v.to_str().ok());\n\n let token = match auth_header {\n Some(header) => match crate::auth::ApiAuth::extract_token(header) {\n Some(token) => token,\n None => {\n return auth_error_response(\"missing_token\", \"Missing or invalid Authorization header\");\n }\n },\n None => {\n return auth_error_response(\"missing_token\", \"Missing Authorization header\");\n }\n };\n\n // Validate token\n match auth.validate_token(token) {\n Ok(claims) => {\n // Store claims in extensions for route handlers\n let mut request = request;\n request.extensions_mut().insert(claims);\n next.run(request).await\n }\n Err(e) => {\n tracing::debug!(\"token validation failed: {}\", e);\n auth_error_response(\"invalid_token\", \"Invalid or expired token\")\n }\n }\n}\n\n/// Create an authentication error response\nfn auth_error_response(error: &str, message: &str) -> Response {\n (StatusCode::UNAUTHORIZED, Json(AuthErrorResponse {\n error: error.to_string(),\n message: message.to_string(),\n })).into_response()\n}\n\n/// Extractor for authenticated user claims\nuse axum::extract::FromRequestParts;\nuse axum::http::request::Parts;\n\n#[derive(Clone, Debug)]\npub struct AuthenticatedUser {\n pub claims: Claims,\n}\n\n#[axum::async_trait]\nimpl FromRequestParts for AuthenticatedUser {\n type Rejection = Response;\n\n async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result {\n let claims = parts\n .extensions\n .get::()\n .cloned()\n .ok_or_else(|| {\n (StatusCode::UNAUTHORIZED, Json(AuthErrorResponse {\n error: \"unauthenticated\".to_string(),\n message: \"No authentication context\".to_string(),\n })).into_response()\n })?;\n\n Ok(AuthenticatedUser { claims })\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use axum::http::Request;\n use chrono::Utc;\n use gitforce_common::UserId;\n\n #[test]\n fn test_public_paths() {\n assert!(PUBLIC_PATHS.iter().any(|p| \"/health\".starts_with(p)));\n assert!(PUBLIC_PATHS.iter().any(|p| \"/metrics\".starts_with(p)));\n assert!(!PUBLIC_PATHS.iter().any(|p| \"/repos\".starts_with(p)));\n }\n\n #[test]\n fn test_auth_error_response() {\n let response = auth_error_response(\"test_error\", \"test message\");\n assert_eq!(response.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_public_paths_includes_swagger_ui() {\n assert!(PUBLIC_PATHS.iter().any(|p| \"/swagger-ui\".starts_with(p)));\n assert!(PUBLIC_PATHS.iter().any(|p| \"/api-docs\".starts_with(p)));\n }\n\n #[test]\n fn test_public_paths_includes_docs() {\n assert!(PUBLIC_PATHS.iter().any(|p| \"/docs\".starts_with(p)));\n }\n\n #[test]\n fn test_auth_error_response_content() {\n let response = auth_error_response(\"invalid_token\", \"Token has expired\");\n assert_eq!(response.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_auth_middleware_skips_public_paths() {\n // Test that the path matching logic works correctly\n let public_path = \"/health\";\n let protected_path = \"/api/repos\";\n\n // Public paths should match their prefixes\n assert!(PUBLIC_PATHS.iter().any(|p| public_path.starts_with(p)));\n // Protected paths should not match public prefixes\n assert!(!PUBLIC_PATHS.iter().any(|p| protected_path.starts_with(p)));\n }\n\n #[test]\n fn test_auth_error_response_missing_token() {\n let response = auth_error_response(\"missing_token\", \"Missing Authorization header\");\n assert_eq!(response.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_auth_error_response_invalid_token() {\n let response = auth_error_response(\"invalid_token\", \"Invalid or expired token\");\n assert_eq!(response.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_authenticated_user_error_response() {\n // Test that the error response for missing auth context has correct status\n let response = auth_error_response(\"unauthenticated\", \"No authentication context\");\n assert_eq!(response.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_authenticated_user_creation() {\n let claims = Claims {\n sub: \"user-123\".to_string(),\n user_id: UserId::new(),\n username: \"testuser\".to_string(),\n role: \"admin\".to_string(),\n exp: Utc::now().timestamp() + 3600,\n iat: Utc::now().timestamp(),\n };\n let user = AuthenticatedUser { claims };\n assert_eq!(user.claims.username, \"testuser\");\n }\n\n #[test]\n fn test_authenticated_user_debug() {\n let claims = Claims {\n sub: \"user-123\".to_string(),\n user_id: UserId::new(),\n username: \"testuser\".to_string(),\n role: \"admin\".to_string(),\n exp: Utc::now().timestamp() + 3600,\n iat: Utc::now().timestamp(),\n };\n let user = AuthenticatedUser { claims };\n let debug_str = format!(\"{:?}\", user);\n assert!(debug_str.contains(\"testuser\"));\n }\n\n #[test]\n fn test_authenticated_user_clone() {\n let claims = Claims {\n sub: \"user-123\".to_string(),\n user_id: UserId::new(),\n username: \"testuser\".to_string(),\n role: \"admin\".to_string(),\n exp: Utc::now().timestamp() + 3600,\n iat: Utc::now().timestamp(),\n };\n let user = AuthenticatedUser { claims };\n let cloned = user.clone();\n assert_eq!(cloned.claims.username, user.claims.username);\n }\n\n #[test]\n fn test_public_paths_exact_match() {\n // Test that paths that are exactly the public path work\n assert!(PUBLIC_PATHS.iter().any(|p| \"/health\" == *p || \"/health\".starts_with(p)));\n assert!(PUBLIC_PATHS.iter().any(|p| \"/metrics\" == *p || \"/metrics\".starts_with(p)));\n }\n\n #[test]\n fn test_auth_error_response_all_variants() {\n // Test all error types\n let resp1 = auth_error_response(\"missing_token\", \"Missing token\");\n assert_eq!(resp1.status(), StatusCode::UNAUTHORIZED);\n\n let resp2 = auth_error_response(\"invalid_token\", \"Invalid token\");\n assert_eq!(resp2.status(), StatusCode::UNAUTHORIZED);\n\n let resp3 = auth_error_response(\"expired_token\", \"Token expired\");\n assert_eq!(resp3.status(), StatusCode::UNAUTHORIZED);\n }\n\n #[test]\n fn test_claims_in_authenticated_user() {\n let user_id = UserId::new();\n let claims = Claims::new(user_id, \"testuser\", \"developer\", 2);\n let user = AuthenticatedUser { claims };\n assert_eq!(user.claims.username, \"testuser\");\n assert_eq!(user.claims.role, \"developer\");\n }\n}\n","traces":[{"line":30,"address":[15493536],"length":1,"stats":{"Line":0}},{"line":35,"address":[12023271,12023406],"length":1,"stats":{"Line":0}},{"line":38,"address":[12023585,12023607,12023471],"length":1,"stats":{"Line":0}},{"line":39,"address":[15953172,15956181],"length":1,"stats":{"Line":0}},{"line":40,"address":[12023316,12026689,12026913],"length":1,"stats":{"Line":0}},{"line":45,"address":[13501761,13501645],"length":1,"stats":{"Line":0}},{"line":48,"address":[12028345,12028336,12023822],"length":1,"stats":{"Line":0}},{"line":50,"address":[12023877],"length":1,"stats":{"Line":0}},{"line":51,"address":[12024050,12023908],"length":1,"stats":{"Line":0}},{"line":52,"address":[15953581],"length":1,"stats":{"Line":0}},{"line":54,"address":[12024175],"length":1,"stats":{"Line":0}},{"line":58,"address":[12023963],"length":1,"stats":{"Line":0}},{"line":63,"address":[12024153,12024303],"length":1,"stats":{"Line":0}},{"line":64,"address":[15953941],"length":1,"stats":{"Line":0}},{"line":66,"address":[12024565],"length":1,"stats":{"Line":0}},{"line":67,"address":[15954107,15954193],"length":1,"stats":{"Line":0}},{"line":68,"address":[12027673,12025049,12023337,12024866],"length":1,"stats":{"Line":0}},{"line":70,"address":[13502289],"length":1,"stats":{"Line":0}},{"line":71,"address":[13503119,13502334,13503539],"length":1,"stats":{"Line":0}},{"line":72,"address":[15955053],"length":1,"stats":{"Line":0}},{"line":78,"address":[15494001,15493995,15493664],"length":1,"stats":{"Line":1}},{"line":79,"address":[15493816],"length":1,"stats":{"Line":3}},{"line":80,"address":[15493723],"length":1,"stats":{"Line":1}},{"line":81,"address":[10579272],"length":1,"stats":{"Line":2}},{"line":82,"address":[15493772,15493971],"length":1,"stats":{"Line":4}},{"line":98,"address":[],"length":0,"stats":{"Line":0}},{"line":99,"address":[],"length":0,"stats":{"Line":0}},{"line":100,"address":[],"length":0,"stats":{"Line":0}},{"line":103,"address":[],"length":0,"stats":{"Line":0}},{"line":104,"address":[],"length":0,"stats":{"Line":0}},{"line":105,"address":[],"length":0,"stats":{"Line":0}},{"line":106,"address":[],"length":0,"stats":{"Line":0}},{"line":107,"address":[],"length":0,"stats":{"Line":0}},{"line":110,"address":[],"length":0,"stats":{"Line":0}}],"covered":5,"coverable":34},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","openapi.rs"],"content":"//! OpenAPI Documentation for GitForge API\n//!\n//! Interactive API documentation available at /swagger-ui when server is running.\n\nuse axum::{\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse serde_json::json;\n\n/// OpenAPI 3.0 specification for GitForge API\npub fn get_openapi_spec() -> serde_json::Value {\n json!({\n \"openapi\": \"3.0.3\",\n \"info\": {\n \"title\": \"GitForge API\",\n \"version\": \"1.0.0\",\n \"description\": \"Self-hosted Git platform with CI/CD capabilities\",\n \"contact\": {\n \"name\": \"GitForge Team\",\n \"email\": \"support@gitforge.dev\"\n },\n \"license\": {\n \"name\": \"MIT\",\n \"url\": \"https://opensource.org/licenses/MIT\"\n }\n },\n \"servers\": [\n {\n \"url\": \"/\",\n \"description\": \"Local server\"\n }\n ],\n \"tags\": [\n {\"name\": \"health\", \"description\": \"Health check endpoints\"},\n {\"name\": \"repos\", \"description\": \"Repository management\"},\n {\"name\": \"ci\", \"description\": \"CI/CD pipelines\"},\n {\"name\": \"runners\", \"description\": \"Runner management\"},\n {\"name\": \"artifacts\", \"description\": \"Artifact management\"}\n ],\n \"paths\": {\n \"/health\": {\n \"get\": {\n \"tags\": [\"health\"],\n \"summary\": \"Health check\",\n \"description\": \"Returns the health status of the API server\",\n \"responses\": {\n \"200\": {\n \"description\": \"Server is healthy\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/HealthResponse\"}\n }\n }\n }\n }\n }\n },\n \"/repos\": {\n \"get\": {\n \"tags\": [\"repos\"],\n \"summary\": \"List repositories\",\n \"description\": \"Returns a list of all repositories\",\n \"responses\": {\n \"200\": {\n \"description\": \"List of repositories\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\n \"type\": \"array\",\n \"items\": {\"$ref\": \"#/components/schemas/RepoResponse\"}\n }\n }\n }\n }\n }\n },\n \"post\": {\n \"tags\": [\"repos\"],\n \"summary\": \"Create repository\",\n \"description\": \"Creates a new repository\",\n \"requestBody\": {\n \"required\": true,\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/CreateRepoRequest\"}\n }\n }\n },\n \"responses\": {\n \"201\": {\n \"description\": \"Repository created\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/RepoResponse\"}\n }\n }\n },\n \"400\": {\"description\": \"Invalid input\"}\n }\n }\n },\n \"/repos/{id}\": {\n \"get\": {\n \"tags\": [\"repos\"],\n \"summary\": \"Get repository\",\n \"description\": \"Returns a repository by ID\",\n \"parameters\": [\n {\n \"name\": \"id\",\n \"in\": \"path\",\n \"required\": true,\n \"schema\": {\"type\": \"string\"}\n }\n ],\n \"responses\": {\n \"200\": {\n \"description\": \"Repository found\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/RepoResponse\"}\n }\n }\n },\n \"404\": {\"description\": \"Repository not found\"}\n }\n },\n \"delete\": {\n \"tags\": [\"repos\"],\n \"summary\": \"Delete repository\",\n \"description\": \"Deletes a repository by ID\",\n \"parameters\": [\n {\n \"name\": \"id\",\n \"in\": \"path\",\n \"required\": true,\n \"schema\": {\"type\": \"string\"}\n }\n ],\n \"responses\": {\n \"204\": {\"description\": \"Repository deleted\"},\n \"404\": {\"description\": \"Repository not found\"}\n }\n }\n },\n \"/pipelines\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"List pipelines\",\n \"description\": \"Returns a list of all pipelines\",\n \"responses\": {\n \"200\": {\"description\": \"List of pipelines\"}\n }\n }\n },\n \"/pipelines/{id}\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"Get pipeline\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\"description\": \"Pipeline found\"},\n \"404\": {\"description\": \"Pipeline not found\"}\n }\n }\n },\n \"/pipeline-runs\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"List pipeline runs\",\n \"responses\": {\n \"200\": {\"description\": \"List of pipeline runs\"}\n }\n }\n },\n \"/pipeline-runs/{id}\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"Get pipeline run\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\n \"description\": \"Pipeline run found\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/PipelineRunResponse\"}\n }\n }\n },\n \"404\": {\"description\": \"Pipeline run not found\"}\n }\n }\n },\n \"/pipeline-runs/{id}/jobs\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"Get pipeline run jobs\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\"description\": \"List of jobs\"}\n }\n }\n },\n \"/jobs/{id}\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"Get job\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\n \"description\": \"Job found\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/JobResponse\"}\n }\n }\n },\n \"404\": {\"description\": \"Job not found\"}\n }\n }\n },\n \"/jobs/{id}/logs\": {\n \"get\": {\n \"tags\": [\"ci\"],\n \"summary\": \"Get job logs\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\"description\": \"Job logs\"}\n }\n }\n },\n \"/runners\": {\n \"get\": {\n \"tags\": [\"runners\"],\n \"summary\": \"List runners\",\n \"responses\": {\n \"200\": {\"description\": \"List of runners\"}\n }\n },\n \"post\": {\n \"tags\": [\"runners\"],\n \"summary\": \"Register runner\",\n \"requestBody\": {\n \"required\": true,\n \"content\": {\n \"application/json\": {\n \"schema\": {\"type\": \"object\"}\n }\n }\n },\n \"responses\": {\n \"201\": {\n \"description\": \"Runner registered\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/RunnerResponse\"}\n }\n }\n }\n }\n }\n },\n \"/runners/{id}\": {\n \"get\": {\n \"tags\": [\"runners\"],\n \"summary\": \"Get runner\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\n \"description\": \"Runner found\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/RunnerResponse\"}\n }\n }\n },\n \"404\": {\"description\": \"Runner not found\"}\n }\n }\n },\n \"/artifacts\": {\n \"get\": {\n \"tags\": [\"artifacts\"],\n \"summary\": \"List artifacts\",\n \"responses\": {\n \"200\": {\"description\": \"List of artifacts\"}\n }\n }\n },\n \"/artifacts/{id}\": {\n \"get\": {\n \"tags\": [\"artifacts\"],\n \"summary\": \"Get artifact\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\n \"description\": \"Artifact found\",\n \"content\": {\n \"application/json\": {\n \"schema\": {\"$ref\": \"#/components/schemas/ArtifactResponse\"}\n }\n }\n },\n \"404\": {\"description\": \"Artifact not found\"}\n }\n },\n \"delete\": {\n \"tags\": [\"artifacts\"],\n \"summary\": \"Delete artifact\",\n \"parameters\": [\n {\"name\": \"id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"204\": {\"description\": \"Artifact deleted\"},\n \"404\": {\"description\": \"Artifact not found\"}\n }\n }\n },\n \"/jobs/{job_id}/artifacts\": {\n \"get\": {\n \"tags\": [\"artifacts\"],\n \"summary\": \"Get job artifacts\",\n \"parameters\": [\n {\"name\": \"job_id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}}\n ],\n \"responses\": {\n \"200\": {\"description\": \"List of job artifacts\"}\n }\n }\n }\n },\n \"components\": {\n \"schemas\": {\n \"HealthResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\"type\": \"string\", \"example\": \"healthy\"},\n \"timestamp\": {\"type\": \"string\", \"example\": \"2024-01-01T00:00:00Z\"}\n }\n },\n \"RepoResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\"type\": \"string\"},\n \"name\": {\"type\": \"string\"},\n \"owner_id\": {\"type\": \"string\"},\n \"visibility\": {\"type\": \"string\"},\n \"git_path\": {\"type\": \"string\"},\n \"created_at\": {\"type\": \"string\"}\n }\n },\n \"CreateRepoRequest\": {\n \"type\": \"object\",\n \"required\": [\"name\"],\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"visibility\": {\"type\": \"string\", \"nullable\": true}\n }\n },\n \"PipelineRunResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\"type\": \"string\"},\n \"pipeline_id\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\"},\n \"commit_hash\": {\"type\": \"string\"},\n \"triggered_by\": {\"type\": \"string\"},\n \"started_at\": {\"type\": \"string\", \"nullable\": true},\n \"finished_at\": {\"type\": \"string\", \"nullable\": true}\n }\n },\n \"JobResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\"type\": \"string\"},\n \"name\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\"},\n \"runner_id\": {\"type\": \"string\", \"nullable\": true},\n \"started_at\": {\"type\": \"string\", \"nullable\": true},\n \"finished_at\": {\"type\": \"string\", \"nullable\": true}\n }\n },\n \"RunnerResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\"type\": \"string\"},\n \"name\": {\"type\": \"string\"},\n \"type\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\"},\n \"capacity\": {\"type\": \"integer\"},\n \"last_heartbeat\": {\"type\": \"string\", \"nullable\": true}\n }\n },\n \"ArtifactResponse\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\"type\": \"string\"},\n \"job_id\": {\"type\": \"string\"},\n \"name\": {\"type\": \"string\"},\n \"path\": {\"type\": \"string\"},\n \"checksum\": {\"type\": \"string\"},\n \"size_bytes\": {\"type\": \"integer\", \"format\": \"int64\"},\n \"created_at\": {\"type\": \"string\"}\n }\n }\n }\n }\n })\n}\n\n/// OpenAPI spec response\npub async fn openapi_spec() -> impl IntoResponse {\n Json(get_openapi_spec())\n}\n\n/// Get the Swagger UI HTML page\npub async fn swagger_ui() -> impl IntoResponse {\n let html = r#\"\n\n\n GitForge API - Swagger UI\n \n \n\n\n
\n \n \n \n\n\"#;\n axum::response::Html(html)\n}\n\n/// Create the API docs router with OpenAPI spec and Swagger UI\npub fn api_docs_routes() -> Router {\n Router::new()\n .route(\"/api-docs/openapi.json\", get(openapi_spec))\n .route(\"/swagger-ui\", get(swagger_ui))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_openapi_spec_contains_required_fields() {\n let spec = get_openapi_spec();\n assert!(spec.get(\"openapi\").is_some());\n assert!(spec.get(\"info\").is_some());\n assert!(spec.get(\"paths\").is_some());\n assert!(spec.get(\"components\").is_some());\n }\n\n #[test]\n fn test_openapi_version() {\n let spec = get_openapi_spec();\n assert_eq!(spec.get(\"openapi\").unwrap(), \"3.0.3\");\n }\n\n #[test]\n fn test_api_info() {\n let spec = get_openapi_spec();\n let info = spec.get(\"info\").unwrap();\n assert_eq!(info.get(\"title\").unwrap(), \"GitForge API\");\n assert_eq!(info.get(\"version\").unwrap(), \"1.0.0\");\n }\n\n #[test]\n fn test_api_paths_exist() {\n let spec = get_openapi_spec();\n let paths = spec.get(\"paths\").unwrap().as_object().unwrap();\n assert!(paths.contains_key(\"/health\"));\n assert!(paths.contains_key(\"/repos\"));\n assert!(paths.contains_key(\"/pipelines\"));\n assert!(paths.contains_key(\"/runners\"));\n assert!(paths.contains_key(\"/artifacts\"));\n }\n\n #[test]\n fn test_api_components_schemas() {\n let spec = get_openapi_spec();\n let components = spec.get(\"components\").unwrap();\n let schemas = components.get(\"schemas\").unwrap().as_object().unwrap();\n assert!(schemas.contains_key(\"HealthResponse\"));\n assert!(schemas.contains_key(\"RepoResponse\"));\n assert!(schemas.contains_key(\"PipelineRunResponse\"));\n assert!(schemas.contains_key(\"JobResponse\"));\n assert!(schemas.contains_key(\"RunnerResponse\"));\n assert!(schemas.contains_key(\"ArtifactResponse\"));\n }\n\n #[tokio::test]\n async fn test_openapi_spec_endpoint() {\n let response = openapi_spec().await.into_response();\n assert_eq!(response.status(), axum::http::StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_swagger_ui_returns_html() {\n let response = swagger_ui().await.into_response();\n assert_eq!(response.status(), axum::http::StatusCode::OK);\n let body = axum::body::to_bytes(response.into_body(), 1024 * 1024).await.unwrap();\n let body_str = String::from_utf8(body.to_vec()).unwrap();\n assert!(body_str.contains(\"swagger-ui\"));\n assert!(body_str.contains(\"GitForge API\"));\n }\n\n #[test]\n fn test_api_docs_routes_creation() {\n let _routes = api_docs_routes();\n // Routes should be created successfully\n assert!(true);\n }\n\n #[test]\n fn test_openapi_spec_servers() {\n let spec = get_openapi_spec();\n let servers = spec.get(\"servers\").unwrap().as_array().unwrap();\n assert!(!servers.is_empty());\n assert_eq!(servers[0].get(\"url\").unwrap(), \"/\");\n }\n\n #[test]\n fn test_openapi_spec_tags() {\n let spec = get_openapi_spec();\n let tags = spec.get(\"tags\").unwrap().as_array().unwrap();\n assert!(tags.len() >= 5);\n }\n\n #[test]\n fn test_openapi_paths_have_operations() {\n let spec = get_openapi_spec();\n let paths = spec.get(\"paths\").unwrap().as_object().unwrap();\n // Health endpoint should have GET\n let health = paths.get(\"/health\").unwrap().as_object().unwrap();\n assert!(health.contains_key(\"get\"));\n }\n}\n","traces":[{"line":13,"address":[15722832,15843499,15835133],"length":1,"stats":{"Line":2}},{"line":14,"address":[11018690,10982408,11015446,11019158,10971771,10978420,11043898,10961593,11031952,11049635,11038413,10957886,10977419,11051380,10988797,10996470,11020505,11050388,10963071,10952869,10964672,11008087,11010977,11048375,11048874,11042907,10968337,10971918,11002875,11037710,10990297,11046239,11051031,11054653,10994973,11060883,10972705,11001336,11054856,11011260,10957174,11034890,11035358,10994655,11003081,11056493,11058890,11000335,11061742,10963963,11030360,10979840,11037563,11046529,11050884,10960998,11060534,10976523,11032591,11004688,11034262,11058747,10973341,11024676,10956818,10963214,11014082,11003977,11050538,11059885,11027698,10967282,10994227,11020645,11002406,11027838,10959590,11040265,11060035,11003827,10975495,10965305,10978130,10959022,11013478,10987376,10996124,11011935,11032242,11050741,11005181,11007335,11055495,11058544,10986306,10983412,11000185,10958096,10988947,11012078,10987236,11020362,11038765,10964176,10962188,10998481,10999439,11038915,11014222,10985128,10995123,11006398,11003299,11063227,10988269,10995977,11021729,10979990,11054999,10982090,10965158,10992482,10996610,11013939,11015156,10998624,11059037,11027062,11041759,10987845,11001046,11017304,11018262,11043698,10973481,11030157,11052528,10990447,11009189,11008721,11027555,11039769,10970202,10976104,10989801,10992335,10998764,11032095,11025267,11027352,11036200,10965018,11040122,10986159,11004831,11024533,11044344,10989948,11015932,11022516,10987093,11025895,10983269,10985810,10966673,11026363,10960613,10982558,11003509,11056643,10956245,10973198,10956605,10992192,11005611,10983908,10971027,11058394,10996757,11013009,10967991,11036347,11040412,11053600,11011120,11016079,11044634,11047377,11004978,11055994,10964382,10966813,10984058,10999657,10960018,10960403,11022796,10977269,11012225,11005464,10991517,10997544,10996327,11017447,11038203,11049024,11053750,11055645,10957736,10961208,10964525,11005324,10997694,11010043,11035208,11013684,11047527,10981243,11042757,11049485,11052378,11059536,11062094,10964875,11043548,10978906,10970345,10978766,11044983,11026213,10975635,11029703,10981456,11030785,11046878,11061532,10976951,11048026,11015792,11052989,10966530,11008293,11020012,10980774,11015299,10974415,11014369,11039919,11023250,11033808,11033528,10984988,11036057,11040911,10993552,11009900,11038056,11048525,10959808,10967844,10972995,11011729,11023746,11037280,10970814,11018056,11056144,11041260,11045886,11047876,11032741,11051530,11037423,10986016,11054503,11033671,11042408,11057491,10980631,11057641,11023599,10978273,10973628,11001189,11034465,10994021,10988051,10993269,10976317,11010190,11017587,11021579,10971631,11037913,11028922,11019008,11009039,11024813,10968827,11041410,11045133,11046036,11006548,10971233,11030575,11023456,11055146,11057142,11061382,10999867,11044487,10958812,11060384,10956395,10962398,10974565,10968194,10978623,11007478,11012866,11020159,10967495,11015649,11031253,10999233,11029563,11020792,11002266,11039125,10958594,10985597,11025470,10975352,10981662,10957024,11040761,10968477,10967701,10991986,11018480,10957384,11022659,11042258,11046382,11052029,10983559,11061033,10961803,10956042,10981880,11025685,11062244,11047028,11056992,11007618,10991234,10991377,11028772,10971488,10963354,10984845,10972848,10963494,10993412,11019869,11027205,11027985,11051879,11059386,11002123,10979053,10994445,11031103,10989658,11053139,10988479,11034680,11041909,11063255,10995834,11008511,10968624,10976741,10968977],"length":1,"stats":{"Line":6}},{"line":427,"address":[10952464],"length":1,"stats":{"Line":6}},{"line":428,"address":[14749894,14749804],"length":1,"stats":{"Line":4}},{"line":432,"address":[15722432],"length":1,"stats":{"Line":6}},{"line":433,"address":[14749666],"length":1,"stats":{"Line":2}},{"line":465,"address":[10952794,10952769,10952480],"length":1,"stats":{"Line":1}},{"line":466,"address":[10952698,10952628,10952487,10952574],"length":1,"stats":{"Line":4}},{"line":467,"address":[10952533,10952805,10952526,10952633,10952587],"length":1,"stats":{"Line":3}},{"line":468,"address":[10952787,10952657,10952664,10952711],"length":1,"stats":{"Line":2}}],"covered":10,"coverable":10},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","routes","artifacts.rs"],"content":"//! Artifact API routes\n\nuse crate::auth::ApiAuth;\nuse crate::server::ErrorResponse;\nuse axum::{\n extract::{Extension, Path},\n http::{HeaderMap, StatusCode},\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse gitforce_storage::{Artifact, ArtifactId, ArtifactStore, FileStorage};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse uuid::Uuid;\n\n/// Artifact response\n#[derive(Debug, Serialize, Deserialize)]\npub struct ArtifactResponse {\n pub id: String,\n pub job_id: String,\n pub name: String,\n pub path: String,\n pub checksum: String,\n pub size_bytes: u64,\n pub created_at: String,\n}\n\n/// Artifact routes\npub fn artifact_routes() -> Router {\n Router::new()\n .route(\"/artifacts\", get(list_artifacts))\n .route(\"/artifacts/{id}\", get(get_artifact).delete(delete_artifact))\n .route(\"/jobs/{job_id}/artifacts\", get(get_job_artifacts))\n}\n\n/// Helper to extract and validate user from headers\nfn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> {\n let auth_header = headers\n .get(\"Authorization\")\n .and_then(|v| v.to_str().ok());\n\n let token = auth_header\n .and_then(|h| ApiAuth::extract_token(h))\n .ok_or(StatusCode::UNAUTHORIZED)?;\n\n auth.validate_token(token)\n .map_err(|_| StatusCode::UNAUTHORIZED)?;\n\n Ok(())\n}\n\n/// Convert storage artifact to response\nfn artifact_to_response(artifact: &Artifact) -> ArtifactResponse {\n ArtifactResponse {\n id: artifact.id.to_string(),\n job_id: artifact.job_id.to_string(),\n name: artifact.name.clone(),\n path: artifact.path.clone(),\n checksum: artifact.checksum.clone(),\n size_bytes: artifact.size_bytes,\n created_at: artifact.created_at.to_rfc3339(),\n }\n}\n\n/// List artifacts\nasync fn list_artifacts(\n Extension(auth): Extension>,\n Extension(storage): Extension>,\n headers: HeaderMap,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"list artifacts\");\n match storage.list().await {\n Ok(artifacts) => {\n let responses: Vec = artifacts\n .iter()\n .map(artifact_to_response)\n .collect();\n Json(responses).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list artifacts: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {\n error: \"storage_error\".to_string(),\n message: e.to_string(),\n })).into_response()\n }\n }\n }\n }\n}\n\n/// Get artifact metadata\nasync fn get_artifact(\n Extension(auth): Extension>,\n Extension(storage): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get artifact: {}\", id);\n\n let artifact_id = match Uuid::parse_str(&id) {\n Ok(uuid) => ArtifactId::from(uuid),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(ErrorResponse {\n error: \"invalid_id\".to_string(),\n message: \"Invalid artifact ID format\".to_string(),\n })).into_response();\n }\n };\n\n match storage.get_metadata(artifact_id).await {\n Ok(artifact) => {\n let response = artifact_to_response(&artifact);\n (StatusCode::OK, Json(response)).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get artifact metadata: {}\", e);\n (StatusCode::NOT_FOUND, Json(ErrorResponse {\n error: \"not_found\".to_string(),\n message: \"Artifact not found\".to_string(),\n })).into_response()\n }\n }\n }\n }\n}\n\n/// Delete artifact\nasync fn delete_artifact(\n Extension(auth): Extension>,\n Extension(storage): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"delete artifact: {}\", id);\n\n let artifact_id = match Uuid::parse_str(&id) {\n Ok(uuid) => ArtifactId::from(uuid),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(ErrorResponse {\n error: \"invalid_id\".to_string(),\n message: \"Invalid artifact ID format\".to_string(),\n })).into_response();\n }\n };\n\n match storage.delete(artifact_id).await {\n Ok(_) => StatusCode::NO_CONTENT.into_response(),\n Err(e) => {\n tracing::error!(\"failed to delete artifact: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {\n error: \"storage_error\".to_string(),\n message: e.to_string(),\n })).into_response()\n }\n }\n }\n }\n}\n\n/// Get artifacts for a job\nasync fn get_job_artifacts(\n Extension(auth): Extension>,\n Extension(storage): Extension>,\n headers: HeaderMap,\n Path(job_id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get job artifacts: {}\", job_id);\n\n let job_id_val = match Uuid::parse_str(&job_id) {\n Ok(uuid) => gitforce_common::JobId::from(uuid),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(ErrorResponse {\n error: \"invalid_id\".to_string(),\n message: \"Invalid job ID format\".to_string(),\n })).into_response();\n }\n };\n\n match storage.list_by_job(job_id_val).await {\n Ok(artifacts) => {\n let responses: Vec = artifacts\n .iter()\n .map(artifact_to_response)\n .collect();\n Json(responses).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list artifacts for job: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {\n error: \"storage_error\".to_string(),\n message: e.to_string(),\n })).into_response()\n }\n }\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_artifact_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let _artifact_id = ArtifactId::from(uuid);\n // Just verify it doesn't panic\n }\n\n #[test]\n fn test_artifact_response_serialization() {\n let response = ArtifactResponse {\n id: \"artifact-123\".to_string(),\n job_id: \"job-456\".to_string(),\n name: \"test-artifact.zip\".to_string(),\n path: \"/tmp/artifact.zip\".to_string(),\n checksum: \"abc123def456\".to_string(),\n size_bytes: 1024,\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"artifact-123\"));\n assert!(json.contains(\"test-artifact.zip\"));\n assert!(json.contains(\"1024\"));\n }\n\n #[test]\n fn test_artifact_response_deserialization() {\n let json = r#\"{\n \"id\": \"artifact-789\",\n \"job_id\": \"job-001\",\n \"name\": \"build-output.tar.gz\",\n \"path\": \"/artifacts/build-output.tar.gz\",\n \"checksum\": \"xyz789abc\",\n \"size_bytes\": 4096,\n \"created_at\": \"2024-01-15T12:30:00Z\"\n }\"#;\n let response: ArtifactResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"artifact-789\");\n assert_eq!(response.name, \"build-output.tar.gz\");\n assert_eq!(response.size_bytes, 4096);\n }\n\n #[test]\n fn test_artifact_to_response_conversion() {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"test.bin\".to_string(),\n path: \"/tmp/test.bin\".to_string(),\n checksum: \"checksum123\".to_string(),\n size_bytes: 256,\n content_type: Some(\"application/octet-stream\".to_string()),\n created_at: chrono::Utc::now(),\n };\n let response = artifact_to_response(&artifact);\n assert_eq!(response.name, \"test.bin\");\n assert_eq!(response.checksum, \"checksum123\");\n assert_eq!(response.size_bytes, 256);\n }\n\n #[test]\n fn test_artifact_response_with_null_content_type() {\n // This tests the artifact_to_response function indirectly\n let response = ArtifactResponse {\n id: \"art-001\".to_string(),\n job_id: \"job-002\".to_string(),\n name: \"data.json\".to_string(),\n path: \"/tmp/data.json\".to_string(),\n checksum: \"chk123\".to_string(),\n size_bytes: 64,\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"art-001\"));\n assert!(json.contains(\"data.json\"));\n }\n\n #[test]\n fn test_artifact_response_debug() {\n let response = ArtifactResponse {\n id: \"art-debug\".to_string(),\n job_id: \"job-debug\".to_string(),\n name: \"debug.bin\".to_string(),\n path: \"/tmp/debug.bin\".to_string(),\n checksum: \"debug123\".to_string(),\n size_bytes: 128,\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let debug_str = format!(\"{:?}\", response);\n assert!(debug_str.contains(\"art-debug\"));\n }\n\n #[test]\n fn test_artifact_response_large_size() {\n let response = ArtifactResponse {\n id: \"large-art\".to_string(),\n job_id: \"large-job\".to_string(),\n name: \"large-file.tar.gz\".to_string(),\n path: \"/tmp/large-file.tar.gz\".to_string(),\n checksum: \"large123\".to_string(),\n size_bytes: 1_073_741_824, // 1GB\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n assert_eq!(response.size_bytes, 1_073_741_824);\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"1073741824\"));\n }\n\n #[test]\n fn test_artifact_response_various_timestamps() {\n for ts in &[\"2024-01-01T00:00:00Z\", \"2025-12-31T23:59:59Z\", \"2026-07-16T12:00:00Z\"] {\n let response = ArtifactResponse {\n id: \"art-ts\".to_string(),\n job_id: \"job-ts\".to_string(),\n name: \"timestamped.bin\".to_string(),\n path: \"/tmp/ts.bin\".to_string(),\n checksum: \"ts123\".to_string(),\n size_bytes: 32,\n created_at: ts.to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(ts));\n }\n }\n\n #[test]\n fn test_artifact_to_response_all_fields() {\n use gitforce_common::JobId;\n let job_id = JobId::new();\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id,\n name: \"full-artifact.bin\".to_string(),\n path: \"/tmp/full.bin\".to_string(),\n checksum: \"fullchecksum\".to_string(),\n size_bytes: 512,\n content_type: Some(\"application/octet-stream\".to_string()),\n created_at: chrono::Utc::now(),\n };\n let response = artifact_to_response(&artifact);\n assert_eq!(response.name, \"full-artifact.bin\");\n assert_eq!(response.checksum, \"fullchecksum\");\n assert_eq!(response.size_bytes, 512);\n }\n\n #[test]\n fn test_artifact_response_deserialization_minimal() {\n let json = r#\"{\"id\":\"min\",\"job_id\":\"min-job\",\"name\":\"min.bin\",\"path\":\"/min\",\"checksum\":\"min\",\"size_bytes\":1,\"created_at\":\"2024-01-01T00:00:00Z\"}\"#;\n let response: ArtifactResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"min\");\n assert_eq!(response.size_bytes, 1);\n }\n}\n","traces":[{"line":30,"address":[14604746,14604224,14604717],"length":1,"stats":{"Line":9}},{"line":31,"address":[11541623,11541367,11541680,11541750,11541462,11541516],"length":1,"stats":{"Line":63}},{"line":32,"address":[11541414,11541421,11541521,11541869,11541475],"length":1,"stats":{"Line":30}},{"line":33,"address":[],"length":0,"stats":{"Line":24}},{"line":34,"address":[],"length":0,"stats":{"Line":22}},{"line":38,"address":[16011872],"length":1,"stats":{"Line":0}},{"line":41,"address":[12091100],"length":1,"stats":{"Line":0}},{"line":43,"address":[13560474,13560417],"length":1,"stats":{"Line":0}},{"line":44,"address":[12395664,12395678],"length":1,"stats":{"Line":0}},{"line":45,"address":[13560461,13560401],"length":1,"stats":{"Line":0}},{"line":47,"address":[12091262,12091296,12091370],"length":1,"stats":{"Line":0}},{"line":48,"address":[14592737,14592736],"length":1,"stats":{"Line":0}},{"line":50,"address":[12091451],"length":1,"stats":{"Line":0}},{"line":54,"address":[16012784,16013383,16013389],"length":1,"stats":{"Line":2}},{"line":56,"address":[16012814],"length":1,"stats":{"Line":2}},{"line":57,"address":[12091985],"length":1,"stats":{"Line":2}},{"line":58,"address":[13561335],"length":1,"stats":{"Line":2}},{"line":59,"address":[16012962],"length":1,"stats":{"Line":2}},{"line":60,"address":[12092173],"length":1,"stats":{"Line":2}},{"line":61,"address":[12092239],"length":1,"stats":{"Line":2}},{"line":62,"address":[12092250],"length":1,"stats":{"Line":2}},{"line":67,"address":[13560880],"length":1,"stats":{"Line":0}},{"line":72,"address":[12402238,12402112],"length":1,"stats":{"Line":0}},{"line":73,"address":[14600831,14599297],"length":1,"stats":{"Line":0}},{"line":75,"address":[14599338,14599780],"length":1,"stats":{"Line":0}},{"line":76,"address":[14599153,14599747,14600893,14600689],"length":1,"stats":{"Line":0}},{"line":77,"address":[14601244],"length":1,"stats":{"Line":0}},{"line":78,"address":[12404284],"length":1,"stats":{"Line":0}},{"line":80,"address":[11538590],"length":1,"stats":{"Line":0}},{"line":82,"address":[12404484],"length":1,"stats":{"Line":0}},{"line":84,"address":[11538349],"length":1,"stats":{"Line":0}},{"line":85,"address":[12404221,12405029,12404622],"length":1,"stats":{"Line":0}},{"line":86,"address":[11540167],"length":1,"stats":{"Line":0}},{"line":87,"address":[11539144],"length":1,"stats":{"Line":0}},{"line":88,"address":[11540116],"length":1,"stats":{"Line":0}},{"line":89,"address":[14603189,14602975],"length":1,"stats":{"Line":0}},{"line":97,"address":[12091472],"length":1,"stats":{"Line":0}},{"line":103,"address":[12396034,12396163],"length":1,"stats":{"Line":0}},{"line":104,"address":[11532997,11530513],"length":1,"stats":{"Line":0}},{"line":106,"address":[14593715,14593266],"length":1,"stats":{"Line":0}},{"line":108,"address":[11532046,11530963],"length":1,"stats":{"Line":0}},{"line":109,"address":[12397850],"length":1,"stats":{"Line":0}},{"line":111,"address":[11532459],"length":1,"stats":{"Line":0}},{"line":112,"address":[14594808],"length":1,"stats":{"Line":0}},{"line":113,"address":[12398120],"length":1,"stats":{"Line":0}},{"line":114,"address":[12398151,12398365],"length":1,"stats":{"Line":0}},{"line":118,"address":[13472181],"length":1,"stats":{"Line":0}},{"line":119,"address":[12399187],"length":1,"stats":{"Line":0}},{"line":120,"address":[11533437],"length":1,"stats":{"Line":0}},{"line":121,"address":[11533540],"length":1,"stats":{"Line":0}},{"line":123,"address":[14596064],"length":1,"stats":{"Line":0}},{"line":124,"address":[12399865,12399120,12399458],"length":1,"stats":{"Line":0}},{"line":125,"address":[11535071],"length":1,"stats":{"Line":0}},{"line":126,"address":[12399824],"length":1,"stats":{"Line":0}},{"line":127,"address":[12400784],"length":1,"stats":{"Line":0}},{"line":128,"address":[12400815,12401029],"length":1,"stats":{"Line":0}},{"line":136,"address":[12091680],"length":1,"stats":{"Line":0}},{"line":142,"address":[12408195,12408066],"length":1,"stats":{"Line":0}},{"line":143,"address":[14605257,14607777],"length":1,"stats":{"Line":0}},{"line":145,"address":[12408306,12408755],"length":1,"stats":{"Line":0}},{"line":147,"address":[11542803,11543886],"length":1,"stats":{"Line":0}},{"line":148,"address":[12409882],"length":1,"stats":{"Line":0}},{"line":150,"address":[14607219],"length":1,"stats":{"Line":0}},{"line":151,"address":[12409848],"length":1,"stats":{"Line":0}},{"line":152,"address":[14607144],"length":1,"stats":{"Line":0}},{"line":153,"address":[12410397,12410183],"length":1,"stats":{"Line":0}},{"line":157,"address":[13475317],"length":1,"stats":{"Line":0}},{"line":158,"address":[11545238],"length":1,"stats":{"Line":0}},{"line":159,"address":[14608114],"length":1,"stats":{"Line":0}},{"line":160,"address":[12411278,12411170,12411685],"length":1,"stats":{"Line":0}},{"line":161,"address":[11546699],"length":1,"stats":{"Line":0}},{"line":162,"address":[12411644],"length":1,"stats":{"Line":0}},{"line":163,"address":[12412604],"length":1,"stats":{"Line":0}},{"line":164,"address":[12412631,12412845],"length":1,"stats":{"Line":0}},{"line":172,"address":[12091808],"length":1,"stats":{"Line":0}},{"line":178,"address":[12413986,12414115],"length":1,"stats":{"Line":0}},{"line":179,"address":[11548193,11550685],"length":1,"stats":{"Line":0}},{"line":181,"address":[14611218,14611667],"length":1,"stats":{"Line":0}},{"line":183,"address":[12414635,12415722],"length":1,"stats":{"Line":0}},{"line":184,"address":[11549806],"length":1,"stats":{"Line":0}},{"line":186,"address":[12416147],"length":1,"stats":{"Line":0}},{"line":187,"address":[14612760],"length":1,"stats":{"Line":0}},{"line":188,"address":[14613064],"length":1,"stats":{"Line":0}},{"line":189,"address":[11550103,11550333],"length":1,"stats":{"Line":0}},{"line":193,"address":[14613762,14612861,14611027],"length":1,"stats":{"Line":0}},{"line":194,"address":[12417121],"length":1,"stats":{"Line":0}},{"line":195,"address":[14614145],"length":1,"stats":{"Line":0}},{"line":197,"address":[11551263],"length":1,"stats":{"Line":0}},{"line":199,"address":[14614345],"length":1,"stats":{"Line":0}},{"line":201,"address":[14614034],"length":1,"stats":{"Line":0}},{"line":202,"address":[11551070,11551445,11551852],"length":1,"stats":{"Line":0}},{"line":203,"address":[11552826],"length":1,"stats":{"Line":0}},{"line":204,"address":[11551811],"length":1,"stats":{"Line":0}},{"line":205,"address":[11552775],"length":1,"stats":{"Line":0}},{"line":206,"address":[11553012,11552782],"length":1,"stats":{"Line":0}}],"covered":13,"coverable":95},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","routes","ci.rs"],"content":"//! CI API routes\n\nuse crate::auth::ApiAuth;\nuse axum::{\n extract::{Extension, Path},\n http::{HeaderMap, StatusCode},\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse gitforce_common::{JobId, PipelineId, PipelineRunId};\nuse gitforce_db::{Pool, queries::{PipelineQueries, PipelineRunQueries, JobQueries}};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse uuid::Uuid;\n\n/// Pipeline run response\n#[derive(Debug, Serialize, Deserialize)]\npub struct PipelineRunResponse {\n pub id: String,\n pub pipeline_id: String,\n pub status: String,\n pub commit_hash: String,\n pub triggered_by: String,\n pub started_at: Option,\n pub finished_at: Option,\n}\n\n/// Job response\n#[derive(Debug, Serialize, Deserialize)]\npub struct JobResponse {\n pub id: String,\n pub name: String,\n pub status: String,\n pub runner_id: Option,\n pub started_at: Option,\n pub finished_at: Option,\n}\n\n/// CI routes\npub fn ci_routes() -> Router {\n Router::new()\n .route(\"/pipelines\", get(list_pipelines))\n .route(\"/pipelines/{id}\", get(get_pipeline))\n .route(\"/pipeline-runs\", get(list_pipeline_runs))\n .route(\"/pipeline-runs/{id}\", get(get_pipeline_run))\n .route(\"/pipeline-runs/{id}/jobs\", get(get_pipeline_run_jobs))\n .route(\"/jobs/{id}\", get(get_job))\n .route(\"/jobs/{id}/logs\", get(get_job_logs))\n}\n\n/// Helper to extract and validate user from headers\nfn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> {\n let auth_header = headers\n .get(\"Authorization\")\n .and_then(|v| v.to_str().ok());\n\n let token = auth_header\n .and_then(|h| ApiAuth::extract_token(h))\n .ok_or(StatusCode::UNAUTHORIZED)?;\n\n auth.validate_token(token)\n .map_err(|_| StatusCode::UNAUTHORIZED)?;\n\n Ok(())\n}\n\n/// List pipelines\nasync fn list_pipelines(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n match PipelineQueries::list(&pool).await {\n Ok(pipelines) => {\n let response: Vec = pipelines.into_iter().map(|p| {\n serde_json::json!({\n \"id\": p.id.to_string(),\n \"repo_id\": p.repo_id.to_string(),\n \"name\": p.name,\n \"trigger_type\": p.trigger_type,\n \"created_at\": p.created_at.to_rfc3339()\n })\n }).collect();\n Json(response).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list pipelines: {}\", e);\n Json(serde_json::Value::Array(vec![])).into_response()\n }\n }\n }\n }\n}\n\n/// Get pipeline\nasync fn get_pipeline(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get pipeline: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let pipeline_id = PipelineId::from(uuid);\n match PipelineQueries::get(&pool, pipeline_id).await {\n Ok(Some(pipeline)) => {\n (StatusCode::OK, Json(serde_json::json!({\n \"id\": pipeline.id.to_string(),\n \"repo_id\": pipeline.repo_id.to_string(),\n \"name\": pipeline.name,\n \"trigger_type\": pipeline.trigger_type,\n \"created_at\": pipeline.created_at.to_rfc3339()\n }))).into_response()\n }\n Ok(None) => {\n (StatusCode::NOT_FOUND, Json(serde_json::json!({\n \"error\": \"not_found\",\n \"message\": \"Pipeline not found\"\n }))).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get pipeline: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to get pipeline: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid pipeline ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n/// List pipeline runs\nasync fn list_pipeline_runs(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n match PipelineRunQueries::list(&pool).await {\n Ok(runs) => {\n let response: Vec = runs.into_iter().map(|r| {\n serde_json::json!({\n \"id\": r.id.to_string(),\n \"pipeline_id\": r.pipeline_id.to_string(),\n \"status\": r.status,\n \"commit_hash\": r.commit_hash,\n \"triggered_by\": r.triggered_by,\n \"started_at\": r.started_at.map(|dt| dt.to_rfc3339()),\n \"finished_at\": r.finished_at.map(|dt| dt.to_rfc3339())\n })\n }).collect();\n Json(response).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list pipeline runs: {}\", e);\n Json(serde_json::Value::Array(vec![])).into_response()\n }\n }\n }\n }\n}\n\n/// Get pipeline run\nasync fn get_pipeline_run(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get pipeline run: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let run_id = PipelineRunId::from(uuid);\n match PipelineRunQueries::get(&pool, run_id).await {\n Ok(Some(run)) => {\n (StatusCode::OK, Json(serde_json::json!({\n \"id\": run.id.to_string(),\n \"pipeline_id\": run.pipeline_id.to_string(),\n \"status\": run.status,\n \"commit_hash\": run.commit_hash,\n \"triggered_by\": run.triggered_by,\n \"started_at\": run.started_at.map(|dt| dt.to_rfc3339()),\n \"finished_at\": run.finished_at.map(|dt| dt.to_rfc3339())\n }))).into_response()\n }\n Ok(None) => {\n (StatusCode::NOT_FOUND, Json(serde_json::json!({\n \"error\": \"not_found\",\n \"message\": \"Pipeline run not found\"\n }))).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get pipeline run: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to get pipeline run: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid pipeline run ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n/// Get pipeline run jobs\nasync fn get_pipeline_run_jobs(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get pipeline run jobs: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let run_id = PipelineRunId::from(uuid);\n match JobQueries::list_by_run(&pool, run_id).await {\n Ok(jobs) => {\n let jobs_json: Vec = jobs.into_iter().map(|j| {\n serde_json::json!({\n \"id\": j.id.to_string(),\n \"name\": j.name,\n \"status\": j.status,\n \"runner_id\": j.runner_id.map(|id| id.to_string()),\n \"started_at\": j.started_at.map(|dt| dt.to_rfc3339()),\n \"finished_at\": j.finished_at.map(|dt| dt.to_rfc3339())\n })\n }).collect();\n Json(jobs_json).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list jobs: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to list jobs: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid pipeline run ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n/// Get job\nasync fn get_job(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get job: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let job_id = JobId::from(uuid);\n match JobQueries::get(&pool, job_id).await {\n Ok(Some(job)) => {\n (StatusCode::OK, Json(serde_json::json!({\n \"id\": job.id.to_string(),\n \"name\": job.name,\n \"status\": job.status,\n \"runner_id\": job.runner_id.map(|id| id.to_string()),\n \"started_at\": job.started_at.map(|dt| dt.to_rfc3339()),\n \"finished_at\": job.finished_at.map(|dt| dt.to_rfc3339())\n }))).into_response()\n }\n Ok(None) => {\n (StatusCode::NOT_FOUND, Json(serde_json::json!({\n \"error\": \"not_found\",\n \"message\": \"Job not found\"\n }))).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get job: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to get job: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid job ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n/// Get job logs (placeholder - logs would come from storage)\nasync fn get_job_logs(\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get job logs: {}\", id);\n (StatusCode::OK, Json(serde_json::json!({\n \"job_id\": id,\n \"logs\": \"Logs not yet implemented - coming soon\"\n }))).into_response()\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_pipeline_run_response_serialization() {\n let response = PipelineRunResponse {\n id: \"run-123\".to_string(),\n pipeline_id: \"pipe-456\".to_string(),\n status: \"running\".to_string(),\n commit_hash: \"abc123\".to_string(),\n triggered_by: \"alice\".to_string(),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"run-123\"));\n assert!(json.contains(\"running\"));\n }\n\n #[test]\n fn test_job_response_serialization() {\n let response = JobResponse {\n id: \"job-789\".to_string(),\n name: \"build\".to_string(),\n status: \"succeeded\".to_string(),\n runner_id: Some(\"runner-1\".to_string()),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: Some(\"2024-01-01T00:05:00Z\".to_string()),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"job-789\"));\n assert!(json.contains(\"build\"));\n assert!(json.contains(\"succeeded\"));\n }\n\n #[test]\n fn test_pipeline_run_response_without_timestamps() {\n let response = PipelineRunResponse {\n id: \"run-001\".to_string(),\n pipeline_id: \"pipe-002\".to_string(),\n status: \"pending\".to_string(),\n commit_hash: \"def456\".to_string(),\n triggered_by: \"bob\".to_string(),\n started_at: None,\n finished_at: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"pending\"));\n assert!(json.contains(\"def456\"));\n }\n\n #[test]\n fn test_job_response_without_runner() {\n let response = JobResponse {\n id: \"job-002\".to_string(),\n name: \"test\".to_string(),\n status: \"queued\".to_string(),\n runner_id: None,\n started_at: None,\n finished_at: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"queued\"));\n assert!(json.contains(\"job-002\"));\n }\n\n #[test]\n fn test_pipeline_run_response_deserialization() {\n let json = r#\"{\n \"id\": \"run-123\",\n \"pipeline_id\": \"pipe-456\",\n \"status\": \"failed\",\n \"commit_hash\": \"xyz789\",\n \"triggered_by\": \"charlie\",\n \"started_at\": \"2024-01-01T00:00:00Z\",\n \"finished_at\": \"2024-01-01T00:10:00Z\"\n }\"#;\n let response: PipelineRunResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"run-123\");\n assert_eq!(response.status, \"failed\");\n assert_eq!(response.triggered_by, \"charlie\");\n }\n\n #[test]\n fn test_job_response_deserialization() {\n let json = r#\"{\n \"id\": \"job-999\",\n \"name\": \"deploy\",\n \"status\": \"running\",\n \"runner_id\": \"runner-5\",\n \"started_at\": \"2024-01-01T00:00:00Z\",\n \"finished_at\": null\n }\"#;\n let response: JobResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"job-999\");\n assert_eq!(response.name, \"deploy\");\n assert_eq!(response.status, \"running\");\n assert_eq!(response.runner_id, Some(\"runner-5\".to_string()));\n }\n\n #[test]\n fn test_pipeline_run_response_debug() {\n let response = PipelineRunResponse {\n id: \"run-debug\".to_string(),\n pipeline_id: \"pipe-debug\".to_string(),\n status: \"debugging\".to_string(),\n commit_hash: \"abc123debug\".to_string(),\n triggered_by: \"debug-user\".to_string(),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: None,\n };\n let debug_str = format!(\"{:?}\", response);\n assert!(debug_str.contains(\"run-debug\"));\n }\n\n #[test]\n fn test_job_response_debug() {\n let response = JobResponse {\n id: \"job-debug\".to_string(),\n name: \"debug-job\".to_string(),\n status: \"debugging\".to_string(),\n runner_id: None,\n started_at: None,\n finished_at: None,\n };\n let debug_str = format!(\"{:?}\", response);\n assert!(debug_str.contains(\"job-debug\"));\n }\n\n #[test]\n fn test_pipeline_run_response_with_all_statuses() {\n for status in &[\"pending\", \"running\", \"succeeded\", \"failed\", \"cancelled\"] {\n let response = PipelineRunResponse {\n id: \"run-status\".to_string(),\n pipeline_id: \"pipe-status\".to_string(),\n status: status.to_string(),\n commit_hash: \"abc123\".to_string(),\n triggered_by: \"test\".to_string(),\n started_at: None,\n finished_at: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(status));\n }\n }\n\n #[test]\n fn test_job_response_with_all_statuses() {\n for status in &[\"queued\", \"assigned\", \"running\", \"succeeded\", \"failed\"] {\n let response = JobResponse {\n id: \"job-status\".to_string(),\n name: \"status-test\".to_string(),\n status: status.to_string(),\n runner_id: None,\n started_at: None,\n finished_at: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(status));\n }\n }\n\n #[test]\n fn test_pipeline_run_response_large_commit_hash() {\n let response = PipelineRunResponse {\n id: \"run-large\".to_string(),\n pipeline_id: \"pipe-large\".to_string(),\n status: \"running\".to_string(),\n commit_hash: \"abc123def456789012345678901234567890\".to_string(),\n triggered_by: \"test\".to_string(),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: None,\n };\n assert!(response.commit_hash.len() > 20);\n }\n\n #[test]\n fn test_job_response_with_runner_assignment() {\n let response = JobResponse {\n id: \"job-assigned\".to_string(),\n name: \"assigned-job\".to_string(),\n status: \"assigned\".to_string(),\n runner_id: Some(\"runner-assigned-123\".to_string()),\n started_at: None,\n finished_at: None,\n };\n assert!(response.runner_id.is_some());\n assert_eq!(response.runner_id.unwrap(), \"runner-assigned-123\");\n }\n\n #[test]\n fn test_pipeline_run_response_complete_cycle() {\n let response = PipelineRunResponse {\n id: \"run-complete\".to_string(),\n pipeline_id: \"pipe-complete\".to_string(),\n status: \"succeeded\".to_string(),\n commit_hash: \"abc123\".to_string(),\n triggered_by: \"ci-bot\".to_string(),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: Some(\"2024-01-01T00:10:00Z\".to_string()),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"succeeded\"));\n assert!(json.contains(\"ci-bot\"));\n }\n\n #[test]\n fn test_job_response_complete_with_timestamps() {\n let response = JobResponse {\n id: \"job-complete\".to_string(),\n name: \"complete-job\".to_string(),\n status: \"succeeded\".to_string(),\n runner_id: Some(\"runner-1\".to_string()),\n started_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n finished_at: Some(\"2024-01-01T00:05:00Z\".to_string()),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"succeeded\"));\n assert!(json.contains(\"runner-1\"));\n }\n}\n","traces":[{"line":41,"address":[15290690,15290661,15289600],"length":1,"stats":{"Line":8}},{"line":42,"address":[12838174,12838313,12838800,12839016,12838039,12838597,12838455,12838232,12838658,12838739,12838516,12838881,12838942,12838374],"length":1,"stats":{"Line":146}},{"line":43,"address":[10757689,10756739,10756785,10756678,10756685],"length":1,"stats":{"Line":24}},{"line":44,"address":[10756870,10756809,10756919,10756816,10757677],"length":1,"stats":{"Line":31}},{"line":45,"address":[],"length":0,"stats":{"Line":28}},{"line":46,"address":[15290105,15290124,15290178,15290231,15290728],"length":1,"stats":{"Line":30}},{"line":47,"address":[],"length":0,"stats":{"Line":30}},{"line":48,"address":[],"length":0,"stats":{"Line":29}},{"line":49,"address":[10757611,10757479,10757533,10757486],"length":1,"stats":{"Line":17}},{"line":53,"address":[12115376],"length":1,"stats":{"Line":0}},{"line":56,"address":[10697728,10697737],"length":1,"stats":{"Line":0}},{"line":58,"address":[12115542,12115486],"length":1,"stats":{"Line":0}},{"line":59,"address":[15232974,15232960],"length":1,"stats":{"Line":0}},{"line":60,"address":[12115471,12115529],"length":1,"stats":{"Line":0}},{"line":62,"address":[12115706,12115632,12115598],"length":1,"stats":{"Line":0}},{"line":63,"address":[10697777,10697776],"length":1,"stats":{"Line":0}},{"line":65,"address":[15647189],"length":1,"stats":{"Line":0}},{"line":69,"address":[15647456],"length":1,"stats":{"Line":0}},{"line":74,"address":[10711674,10711552],"length":1,"stats":{"Line":0}},{"line":75,"address":[10711737,10711918],"length":1,"stats":{"Line":0}},{"line":77,"address":[10711980,10711597,10711786],"length":1,"stats":{"Line":0}},{"line":78,"address":[15247791],"length":1,"stats":{"Line":0}},{"line":79,"address":[10716287,10712355,10716378,10714592],"length":1,"stats":{"Line":0}},{"line":80,"address":[10716293,10714621,10715148,10714713,10714821,10715908,10716265],"length":1,"stats":{"Line":0}},{"line":81,"address":[10714798],"length":1,"stats":{"Line":0}},{"line":82,"address":[15250653],"length":1,"stats":{"Line":0}},{"line":85,"address":[15251437],"length":1,"stats":{"Line":0}},{"line":87,"address":[10714677,10716246,10712449],"length":1,"stats":{"Line":0}},{"line":88,"address":[10712456],"length":1,"stats":{"Line":0}},{"line":90,"address":[12796144],"length":1,"stats":{"Line":0}},{"line":91,"address":[10712567,10712962,10712292],"length":1,"stats":{"Line":0}},{"line":92,"address":[12796849,12797797],"length":1,"stats":{"Line":0}},{"line":100,"address":[15647328],"length":1,"stats":{"Line":0}},{"line":106,"address":[12785258,12785129],"length":1,"stats":{"Line":0}},{"line":107,"address":[12788024,12785328],"length":1,"stats":{"Line":0}},{"line":109,"address":[12785369,12785818],"length":1,"stats":{"Line":0}},{"line":111,"address":[15237346,15238433],"length":1,"stats":{"Line":0}},{"line":112,"address":[15238497],"length":1,"stats":{"Line":0}},{"line":113,"address":[12786945],"length":1,"stats":{"Line":0}},{"line":114,"address":[12786996,12788491,12788060,12785178],"length":1,"stats":{"Line":0}},{"line":115,"address":[15240100],"length":1,"stats":{"Line":0}},{"line":116,"address":[15242606,15241349,15242536,15242352,15241640,15242098,15240236,15242282,15241208,15242677,15243084,15241778,15242028,15241707,15241278,15241170],"length":1,"stats":{"Line":0}},{"line":117,"address":[12789683],"length":1,"stats":{"Line":0}},{"line":118,"address":[10706320],"length":1,"stats":{"Line":0}},{"line":121,"address":[15242579],"length":1,"stats":{"Line":0}},{"line":122,"address":[15243035],"length":1,"stats":{"Line":0}},{"line":125,"address":[10705662,10704922,10704956,10705286,10705059,10705710,10705348,10704994],"length":1,"stats":{"Line":0}},{"line":128,"address":[12789419],"length":1,"stats":{"Line":0}},{"line":130,"address":[10704648],"length":1,"stats":{"Line":0}},{"line":131,"address":[10708309,10707918,10704696],"length":1,"stats":{"Line":0}},{"line":132,"address":[15245964,15244973,15244742,15245194,15244673,15245048,15243688,15244635,15245221],"length":1,"stats":{"Line":0}},{"line":134,"address":[15245092,15245021],"length":1,"stats":{"Line":0}},{"line":135,"address":[10710167],"length":1,"stats":{"Line":0}},{"line":140,"address":[10704236,10704167,10703461,10703499,10703791,10703564,10703853,10703195,10704198],"length":1,"stats":{"Line":0}},{"line":143,"address":[12787896],"length":1,"stats":{"Line":0}},{"line":151,"address":[13197472],"length":1,"stats":{"Line":0}},{"line":156,"address":[10727536,10727658],"length":1,"stats":{"Line":0}},{"line":157,"address":[12812098,12811905],"length":1,"stats":{"Line":0}},{"line":159,"address":[10727581,10727770,10727964],"length":1,"stats":{"Line":0}},{"line":160,"address":[15264079],"length":1,"stats":{"Line":0}},{"line":161,"address":[15268426,15266032,15264111,15268561],"length":1,"stats":{"Line":0}},{"line":162,"address":[10730347,10730784,10731787,10732516,10732544,10730456,10732158,10730238],"length":1,"stats":{"Line":0}},{"line":163,"address":[12814689],"length":1,"stats":{"Line":0}},{"line":164,"address":[15266593],"length":1,"stats":{"Line":0}},{"line":168,"address":[10731736,10733056,10733068],"length":1,"stats":{"Line":0}},{"line":169,"address":[15267983,15268976,15268988],"length":1,"stats":{"Line":0}},{"line":171,"address":[12814567,12816817,12812629],"length":1,"stats":{"Line":0}},{"line":172,"address":[10728440],"length":1,"stats":{"Line":0}},{"line":174,"address":[12812432],"length":1,"stats":{"Line":0}},{"line":175,"address":[15264734,15264339,15264048],"length":1,"stats":{"Line":0}},{"line":176,"address":[12813137,12814085],"length":1,"stats":{"Line":0}},{"line":184,"address":[13197344],"length":1,"stats":{"Line":0}},{"line":190,"address":[12800906,12800777],"length":1,"stats":{"Line":0}},{"line":191,"address":[12803672,12800976],"length":1,"stats":{"Line":0}},{"line":193,"address":[15252585,15253034],"length":1,"stats":{"Line":0}},{"line":195,"address":[12801426,12802513],"length":1,"stats":{"Line":0}},{"line":196,"address":[12802577],"length":1,"stats":{"Line":0}},{"line":197,"address":[10718597],"length":1,"stats":{"Line":0}},{"line":198,"address":[10718648,10716838,10720005,10719680],"length":1,"stats":{"Line":0}},{"line":199,"address":[10720070],"length":1,"stats":{"Line":0}},{"line":200,"address":[15256618,15258830,15256656,15257800,15258338,15257730,15255684,15258659,15257984,15257476,15257088,15258759,15256797,15257155,15258238,15259237,15256726,15258409,15258054,15257546,15257226],"length":1,"stats":{"Line":0}},{"line":201,"address":[10721075],"length":1,"stats":{"Line":0}},{"line":202,"address":[10721496],"length":1,"stats":{"Line":0}},{"line":206,"address":[12811472,12811484,12806713],"length":1,"stats":{"Line":0}},{"line":207,"address":[15258702,15262640,15262652],"length":1,"stats":{"Line":0}},{"line":208,"address":[15259188],"length":1,"stats":{"Line":0}},{"line":211,"address":[12804206,12804506,12804134,12804275,12804894,12804942,12804572,12804168],"length":1,"stats":{"Line":0}},{"line":214,"address":[15256435],"length":1,"stats":{"Line":0}},{"line":216,"address":[15255530],"length":1,"stats":{"Line":0}},{"line":217,"address":[12808357,12807966,12804010],"length":1,"stats":{"Line":0}},{"line":218,"address":[12809846,12809287,12809625,12810616,12809700,12809325,12808340,12809873,12809394],"length":1,"stats":{"Line":0}},{"line":220,"address":[15261241,15261312],"length":1,"stats":{"Line":0}},{"line":221,"address":[12810231],"length":1,"stats":{"Line":0}},{"line":226,"address":[12802952,12803249,12803183,12802567,12803571,12802883,12803640,12802845,12803602],"length":1,"stats":{"Line":0}},{"line":229,"address":[10719536],"length":1,"stats":{"Line":0}},{"line":237,"address":[12116336],"length":1,"stats":{"Line":0}},{"line":243,"address":[15269314,15269443],"length":1,"stats":{"Line":0}},{"line":244,"address":[12820614,12817945],"length":1,"stats":{"Line":0}},{"line":246,"address":[10736906,10737355],"length":1,"stats":{"Line":0}},{"line":248,"address":[10737315,10738398],"length":1,"stats":{"Line":0}},{"line":249,"address":[10738462],"length":1,"stats":{"Line":0}},{"line":250,"address":[12819562],"length":1,"stats":{"Line":0}},{"line":251,"address":[10474801],"length":1,"stats":{"Line":0}},{"line":252,"address":[15272569],"length":1,"stats":{"Line":0}},{"line":253,"address":[10744923,10739909,10742800,10745036],"length":1,"stats":{"Line":0}},{"line":254,"address":[15276593,15275792,15275683,15277713,15275582,15277741,15276970,15277347],"length":1,"stats":{"Line":0}},{"line":255,"address":[15275769],"length":1,"stats":{"Line":0}},{"line":258,"address":[15278672,15278684,15276541],"length":1,"stats":{"Line":0}},{"line":259,"address":[10745804,10745792,10744133],"length":1,"stats":{"Line":0}},{"line":260,"address":[10745856,10745868,10744498],"length":1,"stats":{"Line":0}},{"line":262,"address":[15272687,15277694,15275647],"length":1,"stats":{"Line":0}},{"line":263,"address":[15272714],"length":1,"stats":{"Line":0}},{"line":265,"address":[12820922],"length":1,"stats":{"Line":0}},{"line":266,"address":[10740512,10739846,10740121],"length":1,"stats":{"Line":0}},{"line":267,"address":[10742000,10742712,10740495,10741533,10741468,10741760,10741831,10741430,10741993],"length":1,"stats":{"Line":0}},{"line":269,"address":[10741808,10741875],"length":1,"stats":{"Line":0}},{"line":270,"address":[12823526],"length":1,"stats":{"Line":0}},{"line":275,"address":[12819536,12819894,12820191,12819825,12820582,12820513,12820544,12819787,12820125],"length":1,"stats":{"Line":0}},{"line":278,"address":[12820486],"length":1,"stats":{"Line":0}},{"line":286,"address":[13197680],"length":1,"stats":{"Line":0}},{"line":292,"address":[10746398,10746273],"length":1,"stats":{"Line":0}},{"line":293,"address":[10749124,10746464],"length":1,"stats":{"Line":0}},{"line":295,"address":[10746505,10746954],"length":1,"stats":{"Line":0}},{"line":297,"address":[15279738,15280825],"length":1,"stats":{"Line":0}},{"line":298,"address":[15280889],"length":1,"stats":{"Line":0}},{"line":299,"address":[12829337],"length":1,"stats":{"Line":0}},{"line":300,"address":[15913509],"length":1,"stats":{"Line":0}},{"line":301,"address":[15282418],"length":1,"stats":{"Line":0}},{"line":302,"address":[15284858,15283832,15284153,15284758,15285179,15285279,15283400,15283470,15284929,15282428,15285350,15284337,15283899,15284083,15284508,15285757,15283541,15283362,15284437],"length":1,"stats":{"Line":0}},{"line":303,"address":[12831875],"length":1,"stats":{"Line":0}},{"line":306,"address":[10751468,10756572,10756560],"length":1,"stats":{"Line":0}},{"line":307,"address":[10756128,10756140,10751877],"length":1,"stats":{"Line":0}},{"line":308,"address":[15289568,15289580,15285222],"length":1,"stats":{"Line":0}},{"line":309,"address":[10752776],"length":1,"stats":{"Line":0}},{"line":312,"address":[15282518,15282480,15282446,15282587,15282818,15283254,15283206,15282884],"length":1,"stats":{"Line":0}},{"line":315,"address":[15283179],"length":1,"stats":{"Line":0}},{"line":317,"address":[12830706],"length":1,"stats":{"Line":0}},{"line":318,"address":[10749454,10753080,10753471],"length":1,"stats":{"Line":0}},{"line":319,"address":[15287912,15287460,15287691,15287766,15287391,15288682,15287353,15286406,15287939],"length":1,"stats":{"Line":0}},{"line":321,"address":[10754767,10754834],"length":1,"stats":{"Line":0}},{"line":322,"address":[10755329],"length":1,"stats":{"Line":0}},{"line":327,"address":[15281495,15281914,15281952,15281195,15281264,15280879,15281157,15281561,15281883],"length":1,"stats":{"Line":0}},{"line":330,"address":[15281856],"length":1,"stats":{"Line":0}},{"line":338,"address":[15647216],"length":1,"stats":{"Line":0}},{"line":343,"address":[10698103,10698200],"length":1,"stats":{"Line":0}},{"line":344,"address":[10698259,10700423],"length":1,"stats":{"Line":0}},{"line":346,"address":[10698726,10698300],"length":1,"stats":{"Line":0}},{"line":347,"address":[10699638,10700342,10698709,10699676,10700028,10700363,10699966,10700401,10699742],"length":1,"stats":{"Line":0}},{"line":350,"address":[10700335],"length":1,"stats":{"Line":0}}],"covered":9,"coverable":149},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","routes","mod.rs"],"content":"//! API routes\n\npub mod artifacts;\npub mod ci;\npub mod repo;\npub mod runners;\n\npub use artifacts::*;\npub use ci::*;\npub use repo::*;\npub use runners::*;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","routes","repo.rs"],"content":"//! Repository API routes\n\nuse crate::auth::ApiAuth;\nuse axum::{\n extract::{Extension, Path},\n http::{HeaderMap, StatusCode},\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse gitforce_common::{RepoId, UserId};\nuse gitforce_db::{Pool, queries::RepoQueries};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse uuid::Uuid;\n\n/// Repository response\n#[derive(Debug, Serialize, Deserialize)]\npub struct RepoResponse {\n pub id: String,\n pub name: String,\n pub owner_id: String,\n pub visibility: String,\n pub git_path: String,\n pub created_at: String,\n pub updated_at: String,\n}\n\n/// Create repository request\n#[derive(Debug, Deserialize)]\npub struct CreateRepoRequest {\n pub name: String,\n pub visibility: Option,\n}\n\n/// Repository routes\npub fn repo_routes() -> Router {\n Router::new()\n .route(\"/repos\", get(list_repos).post(create_repo))\n .route(\"/repos/{id}\", get(get_repo).delete(delete_repo))\n}\n\n/// Helper to extract and validate user from headers\nfn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result {\n let auth_header = headers\n .get(\"Authorization\")\n .and_then(|v| v.to_str().ok());\n\n let token = auth_header\n .and_then(|h| ApiAuth::extract_token(h))\n .ok_or(StatusCode::UNAUTHORIZED)?;\n\n let claims = auth.validate_token(token)\n .map_err(|_| StatusCode::UNAUTHORIZED)?;\n\n Ok(claims.user_id)\n}\n\n/// List repositories\nasync fn list_repos(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n) -> impl IntoResponse {\n // Check auth\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n match RepoQueries::list(&pool).await {\n Ok(repos) => {\n let response: Vec = repos.into_iter().map(|r| RepoResponse {\n id: r.id.to_string(),\n name: r.name,\n owner_id: r.owner_id.to_string(),\n visibility: r.visibility,\n git_path: r.git_path,\n created_at: r.created_at.to_rfc3339(),\n updated_at: r.updated_at.to_rfc3339(),\n }).collect();\n Json(response).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list repos: {}\", e);\n Json(serde_json::Value::Array(vec![])).into_response()\n }\n }\n }\n }\n}\n\n/// Create a repository\nasync fn create_repo(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Json(req): Json,\n) -> impl IntoResponse {\n // Check auth and get user\n let owner_id = match extract_user(&auth, &headers) {\n Err(e) => return e.into_response(),\n Ok(id) => id,\n };\n\n tracing::debug!(\"create repo request: {:?} by user {}\", req, owner_id);\n\n let git_path = format!(\"/git/repos/{}\", req.name);\n\n let repo = gitforce_db::models::Repository::new(\n req.name,\n owner_id,\n git_path.clone(),\n );\n\n match RepoQueries::create(&pool, &repo).await {\n Ok(_) => {\n let response = RepoResponse {\n id: repo.id.to_string(),\n name: repo.name,\n owner_id: repo.owner_id.to_string(),\n visibility: repo.visibility,\n git_path: repo.git_path,\n created_at: repo.created_at.to_rfc3339(),\n updated_at: repo.updated_at.to_rfc3339(),\n };\n (StatusCode::CREATED, Json(response)).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to create repo: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to create repository: {}\", e)\n }))).into_response()\n }\n }\n}\n\n/// Get a repository\nasync fn get_repo(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n // Check auth\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get repo request: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let repo_id = RepoId::from(uuid);\n match RepoQueries::get(&pool, repo_id).await {\n Ok(Some(repo)) => {\n let response = RepoResponse {\n id: repo.id.to_string(),\n name: repo.name,\n owner_id: repo.owner_id.to_string(),\n visibility: repo.visibility,\n git_path: repo.git_path,\n created_at: repo.created_at.to_rfc3339(),\n updated_at: repo.updated_at.to_rfc3339(),\n };\n (StatusCode::OK, Json(response)).into_response()\n }\n Ok(None) => {\n (StatusCode::NOT_FOUND, Json(serde_json::json!({\n \"error\": \"not_found\",\n \"message\": \"Repository not found\"\n }))).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get repo: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to get repository: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid repository ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n/// Delete a repository\nasync fn delete_repo(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n // Check auth\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"delete repo request: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let repo_id = RepoId::from(uuid);\n match RepoQueries::delete(&pool, repo_id).await {\n Ok(_) => StatusCode::NO_CONTENT.into_response(),\n Err(e) => {\n tracing::error!(\"failed to delete repo: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to delete repository: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid repository ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_repo_response_serialization() {\n let response = RepoResponse {\n id: \"repo-123\".to_string(),\n name: \"test-repo\".to_string(),\n owner_id: \"user-456\".to_string(),\n visibility: \"private\".to_string(),\n git_path: \"/git/repos/test-repo\".to_string(),\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n updated_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"repo-123\"));\n assert!(json.contains(\"test-repo\"));\n assert!(json.contains(\"private\"));\n }\n\n #[test]\n fn test_repo_response_deserialization() {\n let json = r#\"{\n \"id\": \"repo-789\",\n \"name\": \"my-project\",\n \"owner_id\": \"user-001\",\n \"visibility\": \"public\",\n \"git_path\": \"/git/repos/my-project\",\n \"created_at\": \"2024-01-15T10:30:00Z\",\n \"updated_at\": \"2024-01-15T10:30:00Z\"\n }\"#;\n let response: RepoResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"repo-789\");\n assert_eq!(response.name, \"my-project\");\n assert_eq!(response.visibility, \"public\");\n }\n\n #[test]\n fn test_create_repo_request_deserialization() {\n let json = r#\"{\"name\": \"new-repo\", \"visibility\": \"private\"}\"#;\n let request: CreateRepoRequest = serde_json::from_str(json).unwrap();\n assert_eq!(request.name, \"new-repo\");\n assert_eq!(request.visibility, Some(\"private\".to_string()));\n }\n\n #[test]\n fn test_create_repo_request_without_visibility() {\n let json = r#\"{\"name\": \"another-repo\"}\"#;\n let request: CreateRepoRequest = serde_json::from_str(json).unwrap();\n assert_eq!(request.name, \"another-repo\");\n assert!(request.visibility.is_none());\n }\n\n #[test]\n fn test_repo_response_public_visibility() {\n let response = RepoResponse {\n id: \"repo-001\".to_string(),\n name: \"public-repo\".to_string(),\n owner_id: \"user-100\".to_string(),\n visibility: \"public\".to_string(),\n git_path: \"/git/repos/public-repo\".to_string(),\n created_at: \"2024-01-01T00:00:00Z\".to_string(),\n updated_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"public\"));\n }\n\n #[test]\n fn test_repo_response_all_fields() {\n let response = RepoResponse {\n id: \"repo-full\".to_string(),\n name: \"complete-repo\".to_string(),\n owner_id: \"owner-123\".to_string(),\n visibility: \"private\".to_string(),\n git_path: \"/git/repos/complete-repo\".to_string(),\n created_at: \"2024-06-01T12:00:00Z\".to_string(),\n updated_at: \"2024-06-15T18:30:00Z\".to_string(),\n };\n assert_eq!(response.id, \"repo-full\");\n assert_eq!(response.name, \"complete-repo\");\n assert_eq!(response.git_path, \"/git/repos/complete-repo\");\n }\n}\n","traces":[{"line":37,"address":[15216960,15217373,15217344],"length":1,"stats":{"Line":8}},{"line":38,"address":[15217269,15217096,15216967,15217154],"length":1,"stats":{"Line":39}},{"line":39,"address":[],"length":0,"stats":{"Line":27}},{"line":40,"address":[12055023,12055187,12055111,12055030],"length":1,"stats":{"Line":22}},{"line":44,"address":[14622976],"length":1,"stats":{"Line":0}},{"line":47,"address":[12765881,12765872],"length":1,"stats":{"Line":0}},{"line":49,"address":[10856506,10856567],"length":1,"stats":{"Line":0}},{"line":50,"address":[12055294,12055280],"length":1,"stats":{"Line":0}},{"line":51,"address":[14623150,14623085],"length":1,"stats":{"Line":0}},{"line":53,"address":[12365247,12365283,12365363],"length":1,"stats":{"Line":0}},{"line":54,"address":[12055264,12055265],"length":1,"stats":{"Line":0}},{"line":56,"address":[14623403],"length":1,"stats":{"Line":0}},{"line":60,"address":[10856048],"length":1,"stats":{"Line":0}},{"line":66,"address":[12745760,12745886],"length":1,"stats":{"Line":0}},{"line":67,"address":[12745929,12746122],"length":1,"stats":{"Line":0}},{"line":69,"address":[12035876,12035517,12035682],"length":1,"stats":{"Line":0}},{"line":70,"address":[12746535],"length":1,"stats":{"Line":0}},{"line":71,"address":[15198135,15200744,15200862,15200506,15200064],"length":1,"stats":{"Line":0}},{"line":72,"address":[15200100],"length":1,"stats":{"Line":0}},{"line":73,"address":[15200213],"length":1,"stats":{"Line":0}},{"line":74,"address":[12038302],"length":1,"stats":{"Line":0}},{"line":75,"address":[12038367],"length":1,"stats":{"Line":0}},{"line":76,"address":[12748761],"length":1,"stats":{"Line":0}},{"line":77,"address":[12748793],"length":1,"stats":{"Line":0}},{"line":78,"address":[12038496],"length":1,"stats":{"Line":0}},{"line":79,"address":[15200384,15198221,15200258,15200455,15200736,15200750,15200879,15200168],"length":1,"stats":{"Line":0}},{"line":80,"address":[12036352],"length":1,"stats":{"Line":0}},{"line":82,"address":[12746456],"length":1,"stats":{"Line":0}},{"line":83,"address":[12036188,12036858,12036463],"length":1,"stats":{"Line":0}},{"line":84,"address":[12036829,12037773],"length":1,"stats":{"Line":0}},{"line":92,"address":[12364752],"length":1,"stats":{"Line":0}},{"line":99,"address":[12750062,12750188],"length":1,"stats":{"Line":0}},{"line":100,"address":[12039858],"length":1,"stats":{"Line":0}},{"line":101,"address":[12039907],"length":1,"stats":{"Line":0}},{"line":104,"address":[15201882,15202335],"length":1,"stats":{"Line":0}},{"line":106,"address":[12041538,12040347],"length":1,"stats":{"Line":0}},{"line":109,"address":[12752065],"length":1,"stats":{"Line":0}},{"line":110,"address":[12041714],"length":1,"stats":{"Line":0}},{"line":111,"address":[12041729],"length":1,"stats":{"Line":0}},{"line":114,"address":[13466568],"length":1,"stats":{"Line":0}},{"line":117,"address":[15205115],"length":1,"stats":{"Line":0}},{"line":118,"address":[12753629],"length":1,"stats":{"Line":0}},{"line":119,"address":[12043230],"length":1,"stats":{"Line":0}},{"line":120,"address":[12753744],"length":1,"stats":{"Line":0}},{"line":121,"address":[12043345],"length":1,"stats":{"Line":0}},{"line":122,"address":[12043382],"length":1,"stats":{"Line":0}},{"line":123,"address":[12043460],"length":1,"stats":{"Line":0}},{"line":125,"address":[15205807],"length":1,"stats":{"Line":0}},{"line":127,"address":[12043024],"length":1,"stats":{"Line":0}},{"line":128,"address":[15206413,15206022,15205076],"length":1,"stats":{"Line":0}},{"line":129,"address":[12755781,12756119,12756194,12756342,12754828,12756369,12755888,12757273,12755819],"length":1,"stats":{"Line":0}},{"line":131,"address":[12756238,12756167],"length":1,"stats":{"Line":0}},{"line":132,"address":[12756727],"length":1,"stats":{"Line":0}},{"line":138,"address":[10856896],"length":1,"stats":{"Line":0}},{"line":145,"address":[12059209,12059334],"length":1,"stats":{"Line":0}},{"line":146,"address":[12768248,12770944],"length":1,"stats":{"Line":0}},{"line":148,"address":[12768289,12768738],"length":1,"stats":{"Line":0}},{"line":150,"address":[15220266,15221353],"length":1,"stats":{"Line":0}},{"line":151,"address":[12060973],"length":1,"stats":{"Line":0}},{"line":152,"address":[12769865],"length":1,"stats":{"Line":0}},{"line":153,"address":[15221484,15219690,15222548,15222979],"length":1,"stats":{"Line":0}},{"line":154,"address":[12771452],"length":1,"stats":{"Line":0}},{"line":156,"address":[12771604],"length":1,"stats":{"Line":0}},{"line":157,"address":[15224123],"length":1,"stats":{"Line":0}},{"line":158,"address":[12063659],"length":1,"stats":{"Line":0}},{"line":159,"address":[12063730],"length":1,"stats":{"Line":0}},{"line":160,"address":[12063770],"length":1,"stats":{"Line":0}},{"line":161,"address":[12063810],"length":1,"stats":{"Line":0}},{"line":162,"address":[12063881],"length":1,"stats":{"Line":0}},{"line":164,"address":[15224729],"length":1,"stats":{"Line":0}},{"line":167,"address":[12062798,12062726,12063152,12062863,12063514,12063090,12063466,12062760],"length":1,"stats":{"Line":0}},{"line":170,"address":[15223943],"length":1,"stats":{"Line":0}},{"line":172,"address":[12771332],"length":1,"stats":{"Line":0}},{"line":173,"address":[12064547,12064938,12062464],"length":1,"stats":{"Line":0}},{"line":174,"address":[15227713,15226943,15226720,15226489,15225429,15226420,15226382,15226795,15226970],"length":1,"stats":{"Line":0}},{"line":176,"address":[15226768,15226839],"length":1,"stats":{"Line":0}},{"line":177,"address":[12066804],"length":1,"stats":{"Line":0}},{"line":182,"address":[12770912,12770843,12770521,12770117,12770155,12770224,12769839,12770455,12770874],"length":1,"stats":{"Line":0}},{"line":185,"address":[15222384],"length":1,"stats":{"Line":0}},{"line":193,"address":[12364880],"length":1,"stats":{"Line":0}},{"line":200,"address":[12758626,12758755],"length":1,"stats":{"Line":0}},{"line":201,"address":[15213038,15210369],"length":1,"stats":{"Line":0}},{"line":203,"address":[12759291,12758842],"length":1,"stats":{"Line":0}},{"line":205,"address":[12048763,12049846],"length":1,"stats":{"Line":0}},{"line":206,"address":[15211970],"length":1,"stats":{"Line":0}},{"line":207,"address":[15211986],"length":1,"stats":{"Line":0}},{"line":208,"address":[15918885],"length":1,"stats":{"Line":0}},{"line":209,"address":[12051322],"length":1,"stats":{"Line":0}},{"line":210,"address":[15213346],"length":1,"stats":{"Line":0}},{"line":211,"address":[12051793,12051294,12051402],"length":1,"stats":{"Line":0}},{"line":212,"address":[12053274,12052749,12052711,12052814,12051776,12053993,12053112,12053041,12053281],"length":1,"stats":{"Line":0}},{"line":214,"address":[12053089,12053156],"length":1,"stats":{"Line":0}},{"line":215,"address":[12053651],"length":1,"stats":{"Line":0}},{"line":220,"address":[12049900,12050139,12050242,12050876,12050469,12050914,12050177,12050845,12050531],"length":1,"stats":{"Line":0}},{"line":223,"address":[12050838],"length":1,"stats":{"Line":0}}],"covered":4,"coverable":95},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","routes","runners.rs"],"content":"//! Runner API routes\n\nuse crate::auth::ApiAuth;\nuse axum::{\n extract::{Extension, Path},\n http::{HeaderMap, StatusCode},\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse gitforce_common::RunnerId;\nuse gitforce_db::{Pool, queries::RunnerQueries, models::RunnerType};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse uuid::Uuid;\n\n/// Runner response\n#[derive(Debug, Serialize, Deserialize)]\npub struct RunnerResponse {\n pub id: String,\n pub name: String,\n #[serde(rename = \"type\")]\n pub runner_type: String,\n pub status: String,\n pub capacity: i32,\n pub last_heartbeat: Option,\n}\n\n/// Runner routes\npub fn runner_routes() -> Router {\n Router::new()\n .route(\"/runners\", get(list_runners).post(register_runner))\n .route(\"/runners/{id}\", get(get_runner))\n}\n\n/// Helper to extract and validate user from headers\nfn extract_user(auth: &ApiAuth, headers: &HeaderMap) -> Result<(), StatusCode> {\n let auth_header = headers\n .get(\"Authorization\")\n .and_then(|v| v.to_str().ok());\n\n let token = auth_header\n .and_then(|h| ApiAuth::extract_token(h))\n .ok_or(StatusCode::UNAUTHORIZED)?;\n\n auth.validate_token(token)\n .map_err(|_| StatusCode::UNAUTHORIZED)?;\n\n Ok(())\n}\n\n/// List runners\nasync fn list_runners(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n match RunnerQueries::list(&pool).await {\n Ok(runners) => {\n let response: Vec = runners.into_iter().map(|r| RunnerResponse {\n id: r.id.to_string(),\n name: r.name,\n runner_type: r.runner_type,\n status: r.status,\n capacity: r.capacity,\n last_heartbeat: r.last_heartbeat.map(|dt| dt.to_rfc3339()),\n }).collect();\n Json(response).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to list runners: {}\", e);\n Json(serde_json::Value::Array(vec![])).into_response()\n }\n }\n }\n }\n}\n\n/// Register a runner (also used by runner agent - allows unauthenticated)\nasync fn register_runner(\n Extension(pool): Extension>,\n Json(payload): Json,\n) -> impl IntoResponse {\n tracing::debug!(\"register runner: {:?}\", payload);\n\n let name = payload[\"name\"].as_str().unwrap_or(\"runner\").to_string();\n let runner_type = payload[\"type\"].as_str().unwrap_or(\"docker\").to_string();\n let capacity = payload[\"capacity\"].as_i64().unwrap_or(1) as i32;\n\n let rt = match runner_type.as_str() {\n \"docker\" => RunnerType::Docker,\n \"firecracker\" => RunnerType::Firecracker,\n \"bare_metal\" | \"baremetal\" => RunnerType::BareMetal,\n _ => RunnerType::Docker,\n };\n\n let runner = gitforce_db::models::Runner::new(name, rt, capacity);\n\n match RunnerQueries::create(&pool, &runner).await {\n Ok(_) => {\n let response = RunnerResponse {\n id: runner.id.to_string(),\n name: runner.name,\n runner_type: runner.runner_type,\n status: runner.status,\n capacity: runner.capacity,\n last_heartbeat: runner.last_heartbeat.map(|dt| dt.to_rfc3339()),\n };\n (StatusCode::CREATED, Json(response)).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to register runner: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to register runner: {}\", e)\n }))).into_response()\n }\n }\n}\n\n/// Get runner\nasync fn get_runner(\n Extension(pool): Extension>,\n Extension(auth): Extension>,\n headers: HeaderMap,\n Path(id): Path,\n) -> impl IntoResponse {\n match extract_user(&auth, &headers) {\n Err(e) => e.into_response(),\n Ok(_) => {\n tracing::debug!(\"get runner: {}\", id);\n\n match Uuid::parse_str(&id) {\n Ok(uuid) => {\n let runner_id = RunnerId::from(uuid);\n match RunnerQueries::get(&pool, runner_id).await {\n Ok(Some(runner)) => {\n (StatusCode::OK, Json(RunnerResponse {\n id: runner.id.to_string(),\n name: runner.name,\n runner_type: runner.runner_type,\n status: runner.status,\n capacity: runner.capacity,\n last_heartbeat: runner.last_heartbeat.map(|dt| dt.to_rfc3339()),\n })).into_response()\n }\n Ok(None) => {\n (StatusCode::NOT_FOUND, Json(serde_json::json!({\n \"error\": \"not_found\",\n \"message\": \"Runner not found\"\n }))).into_response()\n }\n Err(e) => {\n tracing::error!(\"failed to get runner: {}\", e);\n (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({\n \"error\": \"database_error\",\n \"message\": format!(\"failed to get runner: {}\", e)\n }))).into_response()\n }\n }\n }\n Err(_) => {\n (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_id\",\n \"message\": \"Invalid runner ID format\"\n }))).into_response()\n }\n }\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_runner_response_serialization() {\n let response = RunnerResponse {\n id: \"runner-123\".to_string(),\n name: \"docker-runner-1\".to_string(),\n runner_type: \"docker\".to_string(),\n status: \"online\".to_string(),\n capacity: 4,\n last_heartbeat: Some(\"2024-01-01T00:00:00Z\".to_string()),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"runner-123\"));\n assert!(json.contains(\"docker\"));\n assert!(json.contains(\"4\"));\n }\n\n #[test]\n fn test_runner_response_deserialization() {\n let json = r#\"{\n \"id\": \"runner-456\",\n \"name\": \"firecracker-runner\",\n \"type\": \"firecracker\",\n \"status\": \"busy\",\n \"capacity\": 8,\n \"last_heartbeat\": \"2024-01-01T12:30:00Z\"\n }\"#;\n let response: RunnerResponse = serde_json::from_str(json).unwrap();\n assert_eq!(response.id, \"runner-456\");\n assert_eq!(response.name, \"firecracker-runner\");\n assert_eq!(response.runner_type, \"firecracker\");\n assert_eq!(response.capacity, 8);\n }\n\n #[test]\n fn test_runner_response_without_heartbeat() {\n let response = RunnerResponse {\n id: \"runner-new\".to_string(),\n name: \"new-runner\".to_string(),\n runner_type: \"docker\".to_string(),\n status: \"online\".to_string(),\n capacity: 2,\n last_heartbeat: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"runner-new\"));\n // The field is still present in JSON but as null\n assert!(json.contains(\"null\") || !json.contains(\"last_heartbeat\"));\n }\n\n #[test]\n fn test_runner_response_all_statuses() {\n for status in &[\"online\", \"offline\", \"busy\"] {\n let response = RunnerResponse {\n id: \"runner-1\".to_string(),\n name: \"test\".to_string(),\n runner_type: \"docker\".to_string(),\n status: status.to_string(),\n capacity: 1,\n last_heartbeat: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(status));\n }\n }\n\n #[test]\n fn test_runner_response_different_types() {\n for rt in &[\"docker\", \"firecracker\", \"bare_metal\"] {\n let response = RunnerResponse {\n id: \"runner-1\".to_string(),\n name: \"test\".to_string(),\n runner_type: rt.to_string(),\n status: \"online\".to_string(),\n capacity: 4,\n last_heartbeat: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(rt));\n }\n }\n\n #[test]\n fn test_runner_response_debug() {\n let response = RunnerResponse {\n id: \"runner-debug\".to_string(),\n name: \"debug-runner\".to_string(),\n runner_type: \"docker\".to_string(),\n status: \"online\".to_string(),\n capacity: 4,\n last_heartbeat: Some(\"2024-01-01T00:00:00Z\".to_string()),\n };\n let debug_str = format!(\"{:?}\", response);\n assert!(debug_str.contains(\"runner-debug\"));\n }\n\n #[test]\n fn test_runner_response_all_capacities() {\n for capacity in &[1, 2, 4, 8, 16, 32] {\n let response = RunnerResponse {\n id: \"runner-cap\".to_string(),\n name: \"capacity-test\".to_string(),\n runner_type: \"docker\".to_string(),\n status: \"online\".to_string(),\n capacity: *capacity,\n last_heartbeat: None,\n };\n assert_eq!(response.capacity, *capacity);\n }\n }\n\n #[test]\n fn test_runner_response_with_special_heartbeat() {\n let response = RunnerResponse {\n id: \"runner-hb\".to_string(),\n name: \"heartbeat-test\".to_string(),\n runner_type: \"firecracker\".to_string(),\n status: \"busy\".to_string(),\n capacity: 4,\n last_heartbeat: Some(\"2026-07-16T22:00:00Z\".to_string()),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"2026-07-16T22:00:00Z\"));\n }\n\n #[test]\n fn test_runner_response_all_runner_types() {\n for rt in &[\"docker\", \"firecracker\", \"bare_metal\", \"kubernetes\"] {\n let response = RunnerResponse {\n id: \"runner-rt\".to_string(),\n name: \"type-test\".to_string(),\n runner_type: rt.to_string(),\n status: \"online\".to_string(),\n capacity: 2,\n last_heartbeat: None,\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(rt));\n }\n }\n}\n","traces":[{"line":30,"address":[15701609,15701638,15701264],"length":1,"stats":{"Line":9}},{"line":31,"address":[],"length":0,"stats":{"Line":42}},{"line":32,"address":[10556785,10557093,10556919,10556792,10556873],"length":1,"stats":{"Line":29}},{"line":33,"address":[],"length":0,"stats":{"Line":18}},{"line":37,"address":[13260624],"length":1,"stats":{"Line":0}},{"line":40,"address":[15697145,15697136],"length":1,"stats":{"Line":0}},{"line":42,"address":[15712362,15712305],"length":1,"stats":{"Line":0}},{"line":43,"address":[15712280],"length":1,"stats":{"Line":0}},{"line":44,"address":[10891673,10891615],"length":1,"stats":{"Line":0}},{"line":46,"address":[10891742,10891776,10891850],"length":1,"stats":{"Line":0}},{"line":47,"address":[10891834,10891755,10891921],"length":1,"stats":{"Line":0}},{"line":49,"address":[13261045],"length":1,"stats":{"Line":0}},{"line":53,"address":[10891952],"length":1,"stats":{"Line":0}},{"line":58,"address":[15697440,15697566],"length":1,"stats":{"Line":0}},{"line":59,"address":[13246258,13246065],"length":1,"stats":{"Line":0}},{"line":61,"address":[15697489,15697682,15697888],"length":1,"stats":{"Line":0}},{"line":62,"address":[15698239],"length":1,"stats":{"Line":0}},{"line":63,"address":[10553795,10556009,10555664,10556289,10556172],"length":1,"stats":{"Line":0}},{"line":64,"address":[10555702],"length":1,"stats":{"Line":0}},{"line":65,"address":[10555816],"length":1,"stats":{"Line":0}},{"line":66,"address":[13249169],"length":1,"stats":{"Line":0}},{"line":67,"address":[13249195],"length":1,"stats":{"Line":0}},{"line":68,"address":[15700795],"length":1,"stats":{"Line":0}},{"line":69,"address":[15700802,15701232,15701244],"length":1,"stats":{"Line":0}},{"line":70,"address":[15701207,15698357,15701078,15700851,15701064,15700666],"length":1,"stats":{"Line":0}},{"line":71,"address":[10553896],"length":1,"stats":{"Line":0}},{"line":73,"address":[13246592],"length":1,"stats":{"Line":0}},{"line":74,"address":[13247326,13246640,13246931],"length":1,"stats":{"Line":0}},{"line":75,"address":[13248245,13247297],"length":1,"stats":{"Line":0}},{"line":83,"address":[13261152],"length":1,"stats":{"Line":0}},{"line":87,"address":[15702423,15702001,15701879],"length":1,"stats":{"Line":0}},{"line":89,"address":[13251915,13250807],"length":1,"stats":{"Line":0}},{"line":90,"address":[15703703,15703616],"length":1,"stats":{"Line":0}},{"line":91,"address":[10559252,10559342],"length":1,"stats":{"Line":0}},{"line":93,"address":[10559439],"length":1,"stats":{"Line":0}},{"line":94,"address":[13252601,13252520],"length":1,"stats":{"Line":0}},{"line":95,"address":[10559559,10559605,10559653],"length":1,"stats":{"Line":0}},{"line":96,"address":[13252643,13252686],"length":1,"stats":{"Line":0}},{"line":97,"address":[15704325],"length":1,"stats":{"Line":0}},{"line":100,"address":[10559899,10559764],"length":1,"stats":{"Line":0}},{"line":102,"address":[10473332],"length":1,"stats":{"Line":0}},{"line":105,"address":[13253771],"length":1,"stats":{"Line":0}},{"line":106,"address":[13253853],"length":1,"stats":{"Line":0}},{"line":107,"address":[10560848],"length":1,"stats":{"Line":0}},{"line":108,"address":[10560882],"length":1,"stats":{"Line":0}},{"line":109,"address":[10560919],"length":1,"stats":{"Line":0}},{"line":110,"address":[15705536,15710160,15710172],"length":1,"stats":{"Line":0}},{"line":112,"address":[13254241],"length":1,"stats":{"Line":0}},{"line":114,"address":[15705252],"length":1,"stats":{"Line":0}},{"line":115,"address":[15705300,15706632,15706241],"length":1,"stats":{"Line":0}},{"line":116,"address":[15707568,15707606,15708129,15707981,15706615,15708156,15709165,15707675,15707906],"length":1,"stats":{"Line":0}},{"line":118,"address":[10563322,10563389],"length":1,"stats":{"Line":0}},{"line":119,"address":[15708514],"length":1,"stats":{"Line":0}},{"line":125,"address":[15712064],"length":1,"stats":{"Line":0}},{"line":131,"address":[13236614,13236743],"length":1,"stats":{"Line":0}},{"line":132,"address":[15691077,15688381],"length":1,"stats":{"Line":0}},{"line":134,"address":[13236854,13237303],"length":1,"stats":{"Line":0}},{"line":136,"address":[13238350,13237263],"length":1,"stats":{"Line":0}},{"line":137,"address":[10545650],"length":1,"stats":{"Line":0}},{"line":138,"address":[15689998],"length":1,"stats":{"Line":0}},{"line":139,"address":[15691544,15691113,15688231,15690049],"length":1,"stats":{"Line":0}},{"line":140,"address":[10547213],"length":1,"stats":{"Line":0}},{"line":141,"address":[13241362],"length":1,"stats":{"Line":0}},{"line":142,"address":[15691737],"length":1,"stats":{"Line":0}},{"line":143,"address":[15692688],"length":1,"stats":{"Line":0}},{"line":144,"address":[15692728],"length":1,"stats":{"Line":0}},{"line":145,"address":[10548376],"length":1,"stats":{"Line":0}},{"line":146,"address":[15692808],"length":1,"stats":{"Line":0}},{"line":147,"address":[10552668,10548430,10552656],"length":1,"stats":{"Line":0}},{"line":148,"address":[15693419,15692879,15693363],"length":1,"stats":{"Line":0}},{"line":151,"address":[15692535,15691847,15691775,15692213,15691916,15692583,15692147,15691809],"length":1,"stats":{"Line":0}},{"line":154,"address":[10548136],"length":1,"stats":{"Line":0}},{"line":156,"address":[10547093],"length":1,"stats":{"Line":0}},{"line":157,"address":[10549240,10549631,10547141],"length":1,"stats":{"Line":0}},{"line":158,"address":[10549614,10550652,10551119,10551112,10550587,10550879,10550549,10550950,10551838],"length":1,"stats":{"Line":0}},{"line":160,"address":[10550994,10550927],"length":1,"stats":{"Line":0}},{"line":161,"address":[10551489],"length":1,"stats":{"Line":0}},{"line":166,"address":[10546643,10545906,10546009,10546236,10546681,10545640,10546298,10546612,10545944],"length":1,"stats":{"Line":0}},{"line":169,"address":[10546605],"length":1,"stats":{"Line":0}}],"covered":4,"coverable":79},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","src","server.rs"],"content":"//! API server\n\nuse crate::auth::ApiAuth;\nuse crate::metrics::Metrics;\nuse crate::metrics_middleware::MetricsLayer;\nuse crate::routes::{artifact_routes, ci_routes, repo_routes, runner_routes};\nuse axum::{\n extract::Extension,\n http::StatusCode,\n response::IntoResponse,\n routing::get,\n Json, Router,\n};\nuse gitforce_db::Pool;\nuse gitforce_storage::FileStorage;\nuse serde::Serialize;\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse tower_http::cors::{Any, CorsLayer};\n\n/// API server\npub struct ApiServer {\n pub router: Router,\n pub port: u16,\n}\n\nimpl ApiServer {\n /// Create a new API server\n pub fn new(jwt_secret: &str, pool: Pool) -> Self {\n Self::with_storage(jwt_secret, pool, None)\n }\n\n /// Create a new API server with custom storage path\n pub fn with_storage(jwt_secret: &str, pool: Pool, storage_path: Option) -> Self {\n let auth = ApiAuth::new(jwt_secret);\n let metrics = Metrics::new();\n let metrics_arc = Arc::new(metrics);\n\n // Configure CORS - restrictive by default\n let cors = CorsLayer::new()\n .allow_origin(Any) // TODO: Configure allowed origins\n .allow_methods(Any)\n .allow_headers(Any);\n\n // Public routes (no auth required)\n let health = Router::new().route(\"/health\", get(health_check));\n let metrics = Router::new().route(\"/metrics\", get(metrics_handler));\n let swagger = Router::new()\n .route(\"/swagger-ui\", get(crate::openapi::swagger_ui))\n .route(\"/api-docs/openapi.json\", get(crate::openapi::openapi_spec));\n let dashboard = crate::dashboard::dashboard_routes();\n\n let mut public_routes = Router::new();\n public_routes = public_routes.merge(health);\n public_routes = public_routes.merge(metrics);\n public_routes = public_routes.merge(dashboard);\n public_routes = public_routes.merge(swagger);\n\n // Protected routes (auth required)\n let protected_routes = Router::new()\n .merge(repo_routes())\n .merge(ci_routes())\n .merge(runner_routes())\n .merge(artifact_routes());\n\n // Metrics layer for automatic request recording\n let metrics_layer = MetricsLayer::new(metrics_arc.clone());\n\n let mut app = public_routes\n .layer(cors)\n .layer(Extension(Arc::new(auth)))\n .layer(metrics_layer)\n .layer(Extension(metrics_arc))\n .layer(Extension(Arc::new(pool)));\n\n // Add storage if path provided (async init not possible here)\n if let Some(path) = storage_path {\n // Storage will be added when initialized async\n let _ = path;\n }\n\n app = app.nest(\"/api\", protected_routes);\n\n Self { router: app, port: 8080 }\n }\n\n /// Add storage extension to the router\n pub fn with_storage_extension(self, storage: Arc) -> Self {\n let app = self.router.layer(Extension(storage));\n Self { router: app, ..self }\n }\n\n /// Set the port\n #[must_use]\n pub fn with_port(mut self, port: u16) -> Self {\n self.port = port;\n self\n }\n\n /// Get the router (for testing)\n pub fn into_router(self) -> Router {\n self.router\n }\n\n /// Start the server\n pub async fn start(self) -> anyhow::Result<()> {\n let addr = SocketAddr::from(([0, 0, 0, 0], self.port));\n tracing::info!(\"API server listening on {}\", addr);\n tracing::info!(\"Swagger UI available at /swagger-ui\");\n tracing::info!(\"OpenAPI spec at /api-docs/openapi.json\");\n tracing::info!(\"Metrics available at /metrics\");\n\n let listener = tokio::net::TcpListener::bind(addr).await?;\n axum::serve(listener, self.router).await?;\n\n Ok(())\n }\n}\n\n/// Health check endpoint\nasync fn health_check(\n Extension(pool): Extension>,\n) -> impl IntoResponse {\n let db_status = match pool.health_check().await {\n Ok(_) => \"connected\",\n Err(e) => {\n tracing::warn!(\"database health check failed: {}\", e);\n \"disconnected\"\n }\n };\n\n let overall_status = if db_status == \"connected\" { \"healthy\" } else { \"unhealthy\" };\n\n Json(HealthResponse {\n status: overall_status.to_string(),\n timestamp: chrono::Utc::now().to_rfc3339(),\n database: db_status.to_string(),\n })\n}\n\n/// Health response\n#[derive(serde::Serialize)]\npub struct HealthResponse {\n pub status: String,\n pub timestamp: String,\n pub database: String,\n}\n\n/// Metrics endpoint handler\nasync fn metrics_handler(\n Extension(metrics): Extension>,\n) -> impl IntoResponse {\n use prometheus::Encoder;\n let encoder = prometheus::TextEncoder::new();\n let metric_families = metrics.registry.gather();\n let mut buffer = Vec::new();\n if let Err(e) = encoder.encode(&metric_families, &mut buffer) {\n tracing::error!(\"Failed to encode metrics: {}\", e);\n }\n String::from_utf8(buffer).unwrap_or_default()\n}\n\n/// Error response\n#[derive(Debug, Serialize)]\npub struct ErrorResponse {\n pub error: String,\n pub message: String,\n}\n\n/// Helper to create an error response\npub fn error_response(status: StatusCode, error: &str, message: &str) -> impl IntoResponse {\n (status, Json(ErrorResponse {\n error: error.to_string(),\n message: message.to_string(),\n }))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_server_creation() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n assert_eq!(server.port, 8080);\n }\n\n #[tokio::test]\n async fn test_health_check() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n let response = health_check(Extension(Arc::new(pool))).await.into_response();\n assert_eq!(response.status(), StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_health_check_no_migration() {\n let pool = Pool::memory().await.unwrap();\n // Don't migrate - health check should still work\n let response = health_check(Extension(Arc::new(pool))).await.into_response();\n // May return unhealthy but shouldn't panic\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n }\n\n #[tokio::test]\n async fn test_error_response_returns_correct_status() {\n let response = error_response(StatusCode::NOT_FOUND, \"not_found\", \"Resource not found\");\n let resp = response.into_response();\n assert_eq!(resp.status(), StatusCode::NOT_FOUND);\n }\n\n #[test]\n fn test_error_response_serialization() {\n let response = ErrorResponse {\n error: \"not_found\".to_string(),\n message: \"Item not found\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"not_found\"));\n assert!(json.contains(\"Item not found\"));\n }\n\n #[test]\n fn test_health_response_serialization() {\n let response = HealthResponse {\n status: \"healthy\".to_string(),\n timestamp: \"2024-01-01T00:00:00Z\".to_string(),\n database: \"connected\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"healthy\"));\n assert!(json.contains(\"connected\"));\n }\n\n #[test]\n fn test_error_response_content() {\n let response = ErrorResponse {\n error: \"bad_request\".to_string(),\n message: \"Invalid input\".to_string(),\n };\n let json = serde_json::to_string(&response).unwrap();\n assert!(json.contains(\"bad_request\"));\n assert!(json.contains(\"Invalid input\"));\n }\n\n #[tokio::test]\n async fn test_server_with_port() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool).with_port(3000);\n assert_eq!(server.port, 3000);\n }\n\n #[tokio::test]\n async fn test_server_with_storage() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::with_storage(\"test-secret\", pool, Some(std::path::PathBuf::from(\"/tmp/storage\")));\n assert_eq!(server.port, 8080);\n }\n\n #[tokio::test]\n async fn test_server_into_router() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let _router = server.into_router();\n // Router was created successfully\n }\n}\n","traces":[{"line":29,"address":[14393472],"length":1,"stats":{"Line":2}},{"line":30,"address":[14393501],"length":1,"stats":{"Line":2}},{"line":34,"address":[14393376,14389184,14392889],"length":1,"stats":{"Line":2}},{"line":35,"address":[12607968,12608256],"length":1,"stats":{"Line":4}},{"line":36,"address":[14389528],"length":1,"stats":{"Line":2}},{"line":37,"address":[14389621],"length":1,"stats":{"Line":12}},{"line":40,"address":[14389674],"length":1,"stats":{"Line":8}},{"line":41,"address":[12608470],"length":1,"stats":{"Line":12}},{"line":42,"address":[14389761],"length":1,"stats":{"Line":8}},{"line":43,"address":[14389788],"length":1,"stats":{"Line":11}},{"line":46,"address":[12608559,12611983,12608627],"length":1,"stats":{"Line":20}},{"line":47,"address":[14390126,14393203,14390058],"length":1,"stats":{"Line":15}},{"line":48,"address":[14390361,14390504,14390588,14390434,14390658,14390293],"length":1,"stats":{"Line":59}},{"line":49,"address":[12280297,12280432,12282927,12280374],"length":1,"stats":{"Line":37}},{"line":50,"address":[14390604,14390666,14390528,14393137],"length":1,"stats":{"Line":23}},{"line":51,"address":[12280594,12280658],"length":1,"stats":{"Line":25}},{"line":53,"address":[12609494,12609567],"length":1,"stats":{"Line":20}},{"line":54,"address":[14390839,14390954],"length":1,"stats":{"Line":20}},{"line":55,"address":[14390970],"length":1,"stats":{"Line":6}},{"line":56,"address":[12609788],"length":1,"stats":{"Line":14}},{"line":57,"address":[12281026],"length":1,"stats":{"Line":6}},{"line":60,"address":[12281387,12281341,12281509,12281104,12281469,12281591,12281631,12281253,12281207],"length":1,"stats":{"Line":94}},{"line":61,"address":[12609987,12610079,12610121,12611785],"length":1,"stats":{"Line":24}},{"line":62,"address":[12281277,12282819,12281357,12281395],"length":1,"stats":{"Line":23}},{"line":63,"address":[12282801,12281411,12281517,12281485],"length":1,"stats":{"Line":27}},{"line":64,"address":[14391787,14392983,14391751,14391673],"length":1,"stats":{"Line":33}},{"line":67,"address":[14391803,14391873],"length":1,"stats":{"Line":20}},{"line":69,"address":[12611201,12611107,12611246,12610782,12610646,12610988],"length":1,"stats":{"Line":58}},{"line":70,"address":[14392054,14391931],"length":1,"stats":{"Line":16}},{"line":71,"address":[12281902,12282104,12282729],"length":1,"stats":{"Line":24}},{"line":72,"address":[12282129],"length":1,"stats":{"Line":8}},{"line":73,"address":[12611070,12611115],"length":1,"stats":{"Line":12}},{"line":74,"address":[12282301,12282711,12282211,12282334],"length":1,"stats":{"Line":41}},{"line":77,"address":[14392534],"length":1,"stats":{"Line":4}},{"line":82,"address":[12611360,12611471],"length":1,"stats":{"Line":20}},{"line":88,"address":[14393408],"length":1,"stats":{"Line":1}},{"line":89,"address":[12283188],"length":1,"stats":{"Line":2}},{"line":95,"address":[14393568],"length":1,"stats":{"Line":2}},{"line":96,"address":[12283333],"length":1,"stats":{"Line":2}},{"line":97,"address":[12612330],"length":1,"stats":{"Line":2}},{"line":101,"address":[12279120],"length":1,"stats":{"Line":11}},{"line":106,"address":[12283296,12283280],"length":1,"stats":{"Line":0}},{"line":107,"address":[15513905],"length":1,"stats":{"Line":0}},{"line":108,"address":[10825812,10826251],"length":1,"stats":{"Line":0}},{"line":109,"address":[15516003,15514537,15515599],"length":1,"stats":{"Line":0}},{"line":110,"address":[15515965,15517315,15516911],"length":1,"stats":{"Line":0}},{"line":111,"address":[10830317,10829887,10828945],"length":1,"stats":{"Line":0}},{"line":113,"address":[15514057,15520372,15519680,15518597,15519561],"length":1,"stats":{"Line":0}},{"line":114,"address":[10832434,10825754,10831780,10832037,10831916],"length":1,"stats":{"Line":0}},{"line":116,"address":[15520734],"length":1,"stats":{"Line":0}},{"line":121,"address":[14388752],"length":1,"stats":{"Line":2}},{"line":124,"address":[15506572,15506641,15506523,15506782],"length":1,"stats":{"Line":6}},{"line":125,"address":[15507127],"length":1,"stats":{"Line":2}},{"line":126,"address":[15507051],"length":1,"stats":{"Line":0}},{"line":127,"address":[10817796,10817232,10817374],"length":1,"stats":{"Line":0}},{"line":128,"address":[15507607],"length":1,"stats":{"Line":0}},{"line":132,"address":[15507154,15508597],"length":1,"stats":{"Line":4}},{"line":134,"address":[10818989],"length":1,"stats":{"Line":2}},{"line":135,"address":[10818780],"length":1,"stats":{"Line":2}},{"line":136,"address":[10818878,10818815],"length":1,"stats":{"Line":4}},{"line":137,"address":[15508784],"length":1,"stats":{"Line":2}},{"line":150,"address":[12279104],"length":1,"stats":{"Line":1}},{"line":154,"address":[10819789],"length":1,"stats":{"Line":1}},{"line":155,"address":[13058170],"length":1,"stats":{"Line":1}},{"line":156,"address":[15509792],"length":1,"stats":{"Line":1}},{"line":157,"address":[15509863,15509960],"length":1,"stats":{"Line":2}},{"line":158,"address":[15510200,15510595,15510055],"length":1,"stats":{"Line":0}},{"line":160,"address":[10821644,10820195],"length":1,"stats":{"Line":2}},{"line":171,"address":[12279085,12278736,12279079],"length":1,"stats":{"Line":1}},{"line":172,"address":[12278924],"length":1,"stats":{"Line":1}},{"line":173,"address":[12278831],"length":1,"stats":{"Line":1}},{"line":174,"address":[12278876],"length":1,"stats":{"Line":1}}],"covered":59,"coverable":72},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-api","tests","integration.rs"],"content":"//! Integration tests for GitForge API\n//!\n//! These tests verify API functionality with database integration.\n\nuse gitforce_api::{ApiAuth, ApiServer};\nuse gitforce_db::Pool;\nuse gitforce_storage::FileStorage;\nuse axum::{\n body::Body,\n http::{Request, StatusCode},\n};\nuse std::sync::Arc;\nuse tower::ServiceExt;\n\n#[tokio::test]\nasync fn test_api_server_creation() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n assert_eq!(server.port, 8080);\n}\n\n#[tokio::test]\nasync fn test_health_check_endpoint() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Build the API server\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n // Make request to health endpoint\n let response = app\n .oneshot(Request::builder().uri(\"/health\").body(Body::empty()).unwrap())\n .await\n .unwrap();\n\n assert_eq!(response.status(), StatusCode::OK);\n}\n\n#[tokio::test]\nasync fn test_metrics_endpoint() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n // Request metrics\n let response = app\n .oneshot(Request::builder().uri(\"/metrics\").body(Body::empty()).unwrap())\n .await\n .unwrap();\n\n assert_eq!(response.status(), StatusCode::OK);\n}\n\n#[tokio::test]\nasync fn test_swagger_ui_endpoint() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let response = app\n .oneshot(Request::builder().uri(\"/swagger-ui\").body(Body::empty()).unwrap())\n .await\n .unwrap();\n\n // Should redirect or return HTML\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::FOUND);\n}\n\n#[tokio::test]\nasync fn test_openapi_spec_endpoint() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let response = app\n .oneshot(Request::builder().uri(\"/api-docs/openapi.json\").body(Body::empty()).unwrap())\n .await\n .unwrap();\n\n assert_eq!(response.status(), StatusCode::OK);\n}\n\n#[tokio::test]\nasync fn test_api_server_with_custom_port() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool).with_port(3000);\n assert_eq!(server.port, 3000);\n}\n\n#[tokio::test]\nasync fn test_cors_preflight() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let response = app\n .oneshot(\n Request::builder()\n .method(\"OPTIONS\")\n .uri(\"/health\")\n .header(\"Origin\", \"http://localhost:3000\")\n .header(\"Access-Control-Request-Method\", \"GET\")\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // CORS preflight should succeed\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::NO_CONTENT);\n}\n\n#[tokio::test]\nasync fn test_auth_with_expired_claims() {\n use chrono::Utc;\n use jsonwebtoken::{encode, Header};\n use serde::{Deserialize, Serialize};\n\n #[derive(Debug, Serialize, Deserialize)]\n struct ExpiredClaims {\n sub: String,\n user_id: gitforce_common::UserId,\n username: String,\n role: String,\n exp: i64,\n iat: i64,\n }\n\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n // Create expired token manually\n let claims = ExpiredClaims {\n sub: user.id.to_string(),\n user_id: user.id,\n username: \"testuser\".to_string(),\n role: \"admin\".to_string(),\n exp: Utc::now().timestamp() - 3600, // Expired 1 hour ago\n iat: Utc::now().timestamp() - 7200,\n };\n\n let token = encode(\n &Header::default(),\n &claims,\n &jsonwebtoken::EncodingKey::from_secret(\"test-secret\".as_bytes()),\n )\n .unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/repos\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Expired tokens should be rejected with 401\n // (Note: May return 500 if queries are not fully implemented)\n assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_protected_route_without_auth_returns_error() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n // Access protected route without auth\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/repos\")\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get UNAUTHORIZED or INTERNAL_SERVER_ERROR (if auth check passes but DB fails)\n assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_create_repo_with_valid_auth() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n // Generate valid token\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n // Create repo\n let response = app\n .oneshot(\n Request::builder()\n .method(\"POST\")\n .uri(\"/api/repos\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .header(\"Content-Type\", \"application/json\")\n .body(Body::from(r#\"{\"name\": \"test-repo\"}\"#))\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should succeed (201) or fail gracefully (500 if query not implemented)\n assert!(response.status() == StatusCode::CREATED || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_get_nonexistent_repo() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/repos/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND or INTERNAL_SERVER_ERROR (if query not implemented)\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_delete_nonexistent_repo() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .method(\"DELETE\")\n .uri(\"/api/repos/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND or INTERNAL_SERVER_ERROR (if query not implemented)\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::NO_CONTENT || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_dashboard_endpoint() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let response = app\n .oneshot(Request::builder().uri(\"/dashboard\").body(Body::empty()).unwrap())\n .await\n .unwrap();\n\n assert_eq!(response.status(), StatusCode::OK);\n}\n\n#[tokio::test]\nasync fn test_api_pipeline_runs_endpoint() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/ci/pipeline-runs\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Accept common success/error codes - route exists but may return 500 if DB query fails\n let status = response.status();\n assert!(\n status == StatusCode::OK\n || status == StatusCode::INTERNAL_SERVER_ERROR\n || status == StatusCode::NOT_FOUND,\n \"Unexpected status: {}\",\n status\n );\n}\n\n#[tokio::test]\nasync fn test_api_artifacts_endpoint() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/artifacts\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get OK or INTERNAL_SERVER_ERROR (if query not implemented)\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_runners_endpoint() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/runners\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get OK or error\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_get_nonexistent_artifact() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/artifacts/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND since artifact doesn't exist\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_delete_nonexistent_artifact() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .method(\"DELETE\")\n .uri(\"/api/artifacts/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND since artifact doesn't exist\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_artifact_invalid_id() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/artifacts/invalid-uuid\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get BAD_REQUEST for invalid UUID or NOT_FOUND (route not matched)\n assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND);\n}\n\n#[tokio::test]\nasync fn test_api_job_artifacts_empty() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/jobs/00000000-0000-0000-0000-000000000000/artifacts\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get OK or INTERNAL_SERVER_ERROR\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR || response.status() == StatusCode::NOT_FOUND);\n}\n\n#[tokio::test]\nasync fn test_api_artifacts_without_auth() {\n let pool = Pool::memory().await.unwrap();\n let temp_dir = tempfile::tempdir().unwrap();\n let storage = FileStorage::new(temp_dir.path()).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool)\n .with_storage_extension(Arc::new(storage));\n let app = server.into_router();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/artifacts\")\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get UNAUTHORIZED or INTERNAL_SERVER_ERROR (if storage check fails first)\n assert!(response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_pipelines_endpoint_list() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipelines\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get OK with empty array\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_pipeline_runs_endpoint_list() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipeline-runs\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get OK with empty array\n assert!(response.status() == StatusCode::OK || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_get_nonexistent_pipeline() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipelines/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND or error\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_get_nonexistent_pipeline_run() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipeline-runs/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND or error\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_pipeline_invalid_id() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipelines/invalid-uuid\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies)\n assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND);\n}\n\n#[tokio::test]\nasync fn test_api_pipeline_run_invalid_id() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/pipeline-runs/invalid-uuid\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies)\n assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND);\n}\n\n#[tokio::test]\nasync fn test_api_get_nonexistent_job() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/jobs/00000000-0000-0000-0000-000000000000\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get NOT_FOUND or error\n assert!(response.status() == StatusCode::NOT_FOUND || response.status() == StatusCode::INTERNAL_SERVER_ERROR);\n}\n\n#[tokio::test]\nasync fn test_api_job_invalid_id() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = gitforce_db::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n gitforce_db::queries::UserQueries::create(&pool, &user).await.unwrap();\n\n let server = ApiServer::new(\"test-secret\", pool);\n let app = server.into_router();\n\n let auth = ApiAuth::new(\"test-secret\");\n let token = auth.generate_token(user.id, \"testuser\", \"admin\").unwrap();\n\n let response = app\n .oneshot(\n Request::builder()\n .uri(\"/api/jobs/invalid-uuid\")\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n .unwrap();\n\n // Should get BAD_REQUEST or NOT_FOUND (routing behavior varies)\n assert!(response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND);\n}\n","traces":[{"line":1,"address":[10916128,10916141],"length":1,"stats":{"Line":2}},{"line":15,"address":[10904550,10904652],"length":1,"stats":{"Line":5}},{"line":16,"address":[10948384,10948402],"length":1,"stats":{"Line":3}},{"line":17,"address":[10947651,10947694,10947761,10947836],"length":1,"stats":{"Line":3}},{"line":18,"address":[10948113],"length":1,"stats":{"Line":1}},{"line":19,"address":[10904683],"length":1,"stats":{"Line":2}},{"line":20,"address":[10948421,10948271],"length":1,"stats":{"Line":3}},{"line":22,"address":[10906406,10906505],"length":1,"stats":{"Line":7}},{"line":23,"address":[10958402,10958384],"length":1,"stats":{"Line":3}},{"line":24,"address":[10958517,10958681,10958583,10958764],"length":1,"stats":{"Line":3}},{"line":25,"address":[10959193,10958595,10959050,10959127],"length":1,"stats":{"Line":3}},{"line":28,"address":[10959462],"length":1,"stats":{"Line":1}},{"line":29,"address":[10959528],"length":1,"stats":{"Line":1}},{"line":32,"address":[10959848,10959560,10959923,10960147,10960286],"length":1,"stats":{"Line":5}},{"line":33,"address":[10959584,10959880,10959807,10959966,10959591,10959690],"length":1,"stats":{"Line":2}},{"line":34,"address":[10960025,10960275,10958610,10959950,10959911],"length":1,"stats":{"Line":4}},{"line":35,"address":[10960414],"length":1,"stats":{"Line":1}},{"line":37,"address":[10960444,10960517],"length":1,"stats":{"Line":3}},{"line":38,"address":[10906468,10906805],"length":1,"stats":{"Line":3}},{"line":40,"address":[10903158,10903257],"length":1,"stats":{"Line":5}},{"line":41,"address":[10941666,10941648],"length":1,"stats":{"Line":3}},{"line":42,"address":[10940158,10940075,10939935,10939997],"length":1,"stats":{"Line":3}},{"line":43,"address":[10940439],"length":1,"stats":{"Line":1}},{"line":44,"address":[10940498],"length":1,"stats":{"Line":1}},{"line":47,"address":[10940530,10940818,10940893,10941122,10941261],"length":1,"stats":{"Line":5}},{"line":48,"address":[10940554,10940777,10940936,10940660,10940964,10940561,10940850],"length":1,"stats":{"Line":2}},{"line":49,"address":[10940881,10941250,10941001,10940920,10940009],"length":1,"stats":{"Line":3}},{"line":50,"address":[10941389],"length":1,"stats":{"Line":1}},{"line":52,"address":[10903288],"length":1,"stats":{"Line":3}},{"line":53,"address":[10903220,10903557],"length":1,"stats":{"Line":3}},{"line":55,"address":[10905113,10905014],"length":1,"stats":{"Line":5}},{"line":56,"address":[10948450,10948432],"length":1,"stats":{"Line":3}},{"line":57,"address":[10948782,10948621,10948559,10948699],"length":1,"stats":{"Line":3}},{"line":58,"address":[10949063],"length":1,"stats":{"Line":1}},{"line":59,"address":[10949122],"length":1,"stats":{"Line":1}},{"line":61,"address":[10949746,10949154,10949885,10949517,10949442],"length":1,"stats":{"Line":5}},{"line":62,"address":[10949401,10949185,10949474,10949284,10949560,10949588,10949178],"length":1,"stats":{"Line":2}},{"line":63,"address":[10862589],"length":1,"stats":{"Line":3}},{"line":64,"address":[10950013],"length":1,"stats":{"Line":1}},{"line":67,"address":[10905144],"length":1,"stats":{"Line":3}},{"line":68,"address":[10905413,10905076],"length":1,"stats":{"Line":3}},{"line":70,"address":[10906870,10906969],"length":1,"stats":{"Line":5}},{"line":71,"address":[10907290,10907296,10906848],"length":1,"stats":{"Line":3}},{"line":72,"address":[10960783,10960845,10960923,10961006],"length":1,"stats":{"Line":3}},{"line":73,"address":[10961287],"length":1,"stats":{"Line":1}},{"line":74,"address":[10961346],"length":1,"stats":{"Line":1}},{"line":76,"address":[10961970,10961378,10961666,10961741,10962109],"length":1,"stats":{"Line":5}},{"line":77,"address":[10961698,10961784,10961812,10961402,10961508,10961409,10961625],"length":1,"stats":{"Line":2}},{"line":78,"address":[10864525],"length":1,"stats":{"Line":3}},{"line":79,"address":[10962237],"length":1,"stats":{"Line":1}},{"line":81,"address":[10907000],"length":1,"stats":{"Line":3}},{"line":82,"address":[10962441,10962533,10962291],"length":1,"stats":{"Line":3}},{"line":84,"address":[10912540,10912438],"length":1,"stats":{"Line":5}},{"line":85,"address":[10912416,10912867,10912861],"length":1,"stats":{"Line":3}},{"line":86,"address":[11013107,11013292,11013217,11013150],"length":1,"stats":{"Line":3}},{"line":87,"address":[11013569],"length":1,"stats":{"Line":1}},{"line":88,"address":[10912571],"length":1,"stats":{"Line":2}},{"line":89,"address":[10912503,10912840],"length":1,"stats":{"Line":3}},{"line":91,"address":[10938002,10938056,10937928,10937888,10939831,10939124,10938036,10938159],"length":1,"stats":{"Line":5}},{"line":92,"address":[10902672,10903114,10903120],"length":1,"stats":{"Line":3}},{"line":93,"address":[10937967,10938107,10938029,10938190],"length":1,"stats":{"Line":3}},{"line":94,"address":[10938471],"length":1,"stats":{"Line":1}},{"line":95,"address":[10938530],"length":1,"stats":{"Line":1}},{"line":97,"address":[10939288,10939059,10938984,10938562,10939427],"length":1,"stats":{"Line":5}},{"line":98,"address":[10939016],"length":1,"stats":{"Line":1}},{"line":99,"address":[10938586,10938874],"length":1,"stats":{"Line":2}},{"line":100,"address":[10938628],"length":1,"stats":{"Line":1}},{"line":101,"address":[10938663],"length":1,"stats":{"Line":1}},{"line":102,"address":[10938698],"length":1,"stats":{"Line":1}},{"line":103,"address":[10938746],"length":1,"stats":{"Line":1}},{"line":104,"address":[10938823,10938802,10939102,10938593,10938898],"length":1,"stats":{"Line":2}},{"line":105,"address":[10938913],"length":1,"stats":{"Line":1}},{"line":106,"address":[10939130,10938943,10939023],"length":1,"stats":{"Line":1}},{"line":107,"address":[10861405],"length":1,"stats":{"Line":3}},{"line":108,"address":[10939555],"length":1,"stats":{"Line":1}},{"line":111,"address":[10902824],"length":1,"stats":{"Line":3}},{"line":112,"address":[10902756,10903093],"length":1,"stats":{"Line":3}},{"line":114,"address":[10910220,10910118],"length":1,"stats":{"Line":7}},{"line":115,"address":[10910541,10910096,10910547],"length":1,"stats":{"Line":3}},{"line":120,"address":[10916251,10916473,10916160,10916537,10916512,10916448,10916196],"length":1,"stats":{"Line":2}},{"line":123,"address":[10916211],"length":1,"stats":{"Line":0}},{"line":124,"address":[10916218],"length":1,"stats":{"Line":0}},{"line":125,"address":[10916225],"length":1,"stats":{"Line":0}},{"line":126,"address":[10916232],"length":1,"stats":{"Line":0}},{"line":127,"address":[10916239],"length":1,"stats":{"Line":0}},{"line":130,"address":[10991382,10991139,10991239,10991480],"length":1,"stats":{"Line":3}},{"line":131,"address":[10868558],"length":1,"stats":{"Line":3}},{"line":133,"address":[10992478],"length":1,"stats":{"Line":1}},{"line":134,"address":[10992233,10992299],"length":1,"stats":{"Line":2}},{"line":135,"address":[10992390,10992307],"length":1,"stats":{"Line":2}},{"line":136,"address":[10992398],"length":1,"stats":{"Line":1}},{"line":137,"address":[10992541,10992714,10992429,10992264,10992338],"length":1,"stats":{"Line":1}},{"line":138,"address":[10868573],"length":1,"stats":{"Line":3}},{"line":140,"address":[10993096],"length":1,"stats":{"Line":1}},{"line":141,"address":[10993162,10993233],"length":1,"stats":{"Line":2}},{"line":144,"address":[10993735],"length":1,"stats":{"Line":1}},{"line":145,"address":[10993245],"length":1,"stats":{"Line":1}},{"line":146,"address":[10993318],"length":1,"stats":{"Line":1}},{"line":147,"address":[10993330],"length":1,"stats":{"Line":1}},{"line":148,"address":[10993402],"length":1,"stats":{"Line":1}},{"line":149,"address":[10993540,10993477,10993604],"length":1,"stats":{"Line":2}},{"line":150,"address":[10993870,10993585,10993668],"length":1,"stats":{"Line":2}},{"line":151,"address":[10993496,10993624,10995079,10993361,10993433],"length":1,"stats":{"Line":0}},{"line":153,"address":[10994080,10994136],"length":1,"stats":{"Line":2}},{"line":154,"address":[10993851],"length":1,"stats":{"Line":1}},{"line":155,"address":[10993943],"length":1,"stats":{"Line":1}},{"line":156,"address":[10993952,10994032],"length":1,"stats":{"Line":2}},{"line":158,"address":[10993981,10994143,10994087],"length":1,"stats":{"Line":1}},{"line":160,"address":[10994938,10995331,10995473,10994860,10994303],"length":1,"stats":{"Line":5}},{"line":161,"address":[10994892],"length":1,"stats":{"Line":1}},{"line":162,"address":[10994750,10994339,10994611],"length":1,"stats":{"Line":3}},{"line":163,"address":[10994381],"length":1,"stats":{"Line":1}},{"line":164,"address":[10994432,10995025,10994346,10994466,10994619],"length":1,"stats":{"Line":2}},{"line":165,"address":[10994678,10994699,10994774,10994993],"length":1,"stats":{"Line":2}},{"line":166,"address":[10994789],"length":1,"stats":{"Line":1}},{"line":167,"address":[10994819,10994899,10995047],"length":1,"stats":{"Line":1}},{"line":168,"address":[10991302,10995191,10994923,10995459,10994971],"length":1,"stats":{"Line":3}},{"line":169,"address":[10995601],"length":1,"stats":{"Line":1}},{"line":173,"address":[10910251],"length":1,"stats":{"Line":4}},{"line":174,"address":[10994252,10992591,10991323,10993899,10995655,10995986,10996069,10995881,10991820,10993189,10995772,10993276],"length":1,"stats":{"Line":4}},{"line":176,"address":[10915785,10915686],"length":1,"stats":{"Line":6}},{"line":177,"address":[10916106,10916112,10915664],"length":1,"stats":{"Line":3}},{"line":178,"address":[11041177,11041260,11041013,11041079],"length":1,"stats":{"Line":3}},{"line":179,"address":[10858894],"length":1,"stats":{"Line":3}},{"line":180,"address":[11041958],"length":1,"stats":{"Line":1}},{"line":181,"address":[11042024],"length":1,"stats":{"Line":1}},{"line":184,"address":[11042782,11042056,11042419,11042344,11042643],"length":1,"stats":{"Line":5}},{"line":185,"address":[11042376],"length":1,"stats":{"Line":1}},{"line":186,"address":[11042234,11042080],"length":1,"stats":{"Line":2}},{"line":187,"address":[11042122],"length":1,"stats":{"Line":1}},{"line":188,"address":[11042087,11042462,11042165,11042258,11042186],"length":1,"stats":{"Line":2}},{"line":189,"address":[11042273],"length":1,"stats":{"Line":1}},{"line":190,"address":[11042484,11042383,11042303],"length":1,"stats":{"Line":1}},{"line":191,"address":[10858909],"length":1,"stats":{"Line":3}},{"line":192,"address":[11042910],"length":1,"stats":{"Line":1}},{"line":195,"address":[10915816],"length":1,"stats":{"Line":4}},{"line":196,"address":[10915748,10916085],"length":1,"stats":{"Line":4}},{"line":198,"address":[11013978,11014318,11014776,11017946,11014136,11013920,11014093,11014208,11014157,11014178],"length":1,"stats":{"Line":7}},{"line":199,"address":[10913331,10913325,10912880],"length":1,"stats":{"Line":3}},{"line":200,"address":[11014349,11014023,11014123,11014263],"length":1,"stats":{"Line":3}},{"line":201,"address":[10871182],"length":1,"stats":{"Line":3}},{"line":204,"address":[11015302],"length":1,"stats":{"Line":1}},{"line":205,"address":[11015126,11015060],"length":1,"stats":{"Line":2}},{"line":206,"address":[11015217,11015134],"length":1,"stats":{"Line":2}},{"line":207,"address":[11015225],"length":1,"stats":{"Line":1}},{"line":208,"address":[11015165,11015256,11015520,11015091,11015362],"length":1,"stats":{"Line":1}},{"line":209,"address":[11015465,11015609,11015378,11014165],"length":1,"stats":{"Line":3}},{"line":211,"address":[11015887],"length":1,"stats":{"Line":1}},{"line":212,"address":[11016018,11015953],"length":1,"stats":{"Line":2}},{"line":215,"address":[11016030],"length":1,"stats":{"Line":1}},{"line":216,"address":[11016236,11016104],"length":1,"stats":{"Line":2}},{"line":219,"address":[11017471,11016267,11016945,11017065,11017329],"length":1,"stats":{"Line":5}},{"line":220,"address":[11016977],"length":1,"stats":{"Line":1}},{"line":221,"address":[11016303,11016604,11016835],"length":1,"stats":{"Line":3}},{"line":222,"address":[11016345],"length":1,"stats":{"Line":1}},{"line":223,"address":[11016380],"length":1,"stats":{"Line":1}},{"line":224,"address":[11016428,11016459,11017136,11016310,11016612],"length":1,"stats":{"Line":2}},{"line":225,"address":[11016663,11016746],"length":1,"stats":{"Line":2}},{"line":226,"address":[11016787,11016711,11016754,11016859,11017114],"length":1,"stats":{"Line":2}},{"line":227,"address":[11016874],"length":1,"stats":{"Line":1}},{"line":228,"address":[11016984,11017158,11016904],"length":1,"stats":{"Line":1}},{"line":229,"address":[10871215],"length":1,"stats":{"Line":3}},{"line":230,"address":[11017599],"length":1,"stats":{"Line":1}},{"line":233,"address":[11017702,11017629,11017794],"length":1,"stats":{"Line":4}},{"line":234,"address":[10871359,10871403,10871381,10871438,10871517,10871457],"length":1,"stats":{"Line":4}},{"line":236,"address":[10906044,10905942],"length":1,"stats":{"Line":7}},{"line":237,"address":[10905920,10906365,10906371],"length":1,"stats":{"Line":3}},{"line":238,"address":[10954743,10954603,10954503,10954829],"length":1,"stats":{"Line":3}},{"line":239,"address":[10954624,10955124,10955201,10955267],"length":1,"stats":{"Line":3}},{"line":241,"address":[10955782],"length":1,"stats":{"Line":1}},{"line":242,"address":[10955540,10955606],"length":1,"stats":{"Line":2}},{"line":243,"address":[10955614,10955697],"length":1,"stats":{"Line":2}},{"line":244,"address":[10955705],"length":1,"stats":{"Line":1}},{"line":245,"address":[10955842,10956000,10955571,10955736,10955645],"length":1,"stats":{"Line":1}},{"line":246,"address":[10863725],"length":1,"stats":{"Line":3}},{"line":248,"address":[10956367],"length":1,"stats":{"Line":1}},{"line":249,"address":[10956498,10956433],"length":1,"stats":{"Line":2}},{"line":251,"address":[10956510],"length":1,"stats":{"Line":1}},{"line":252,"address":[10956716,10956584],"length":1,"stats":{"Line":2}},{"line":254,"address":[10956747,10957298,10957418,10957695,10957837],"length":1,"stats":{"Line":5}},{"line":255,"address":[10957330],"length":1,"stats":{"Line":1}},{"line":256,"address":[10956783,10957049,10957188],"length":1,"stats":{"Line":3}},{"line":257,"address":[10956825],"length":1,"stats":{"Line":1}},{"line":258,"address":[10956790,10957502,10957057,10956873,10956904],"length":1,"stats":{"Line":2}},{"line":259,"address":[10957116,10957137,10957212,10957470],"length":1,"stats":{"Line":2}},{"line":260,"address":[10957227],"length":1,"stats":{"Line":1}},{"line":261,"address":[10957257,10957337,10957524],"length":1,"stats":{"Line":1}},{"line":262,"address":[10863743],"length":1,"stats":{"Line":3}},{"line":263,"address":[10957965],"length":1,"stats":{"Line":1}},{"line":266,"address":[10906075],"length":1,"stats":{"Line":3}},{"line":267,"address":[10958136,10956545,10954389,10955889,10956457,10958019,10956668,10954684,10958241,10958341,10957373,10955148],"length":1,"stats":{"Line":4}},{"line":269,"address":[10909756,10909654],"length":1,"stats":{"Line":7}},{"line":270,"address":[10909632,10910083,10910077],"length":1,"stats":{"Line":3}},{"line":271,"address":[10986951,10986851,10987189,10987094],"length":1,"stats":{"Line":3}},{"line":272,"address":[10868014],"length":1,"stats":{"Line":3}},{"line":274,"address":[10988172],"length":1,"stats":{"Line":1}},{"line":275,"address":[10987993,10987927],"length":1,"stats":{"Line":2}},{"line":276,"address":[10988084,10988001],"length":1,"stats":{"Line":2}},{"line":277,"address":[10988092],"length":1,"stats":{"Line":1}},{"line":278,"address":[10988032,10988123,10988235,10988405,10987958],"length":1,"stats":{"Line":1}},{"line":279,"address":[10986993,10988497,10988341,10988251],"length":1,"stats":{"Line":3}},{"line":281,"address":[10988784],"length":1,"stats":{"Line":1}},{"line":282,"address":[10988921,10988850],"length":1,"stats":{"Line":2}},{"line":284,"address":[10988933],"length":1,"stats":{"Line":1}},{"line":285,"address":[10989013,10989151],"length":1,"stats":{"Line":2}},{"line":287,"address":[10990331,10989185,10989774,10990189,10989900],"length":1,"stats":{"Line":5}},{"line":288,"address":[10989806],"length":1,"stats":{"Line":1}},{"line":289,"address":[10989525,10989221,10989664],"length":1,"stats":{"Line":3}},{"line":290,"address":[10989263],"length":1,"stats":{"Line":1}},{"line":291,"address":[10989298],"length":1,"stats":{"Line":1}},{"line":292,"address":[10989349,10989228,10989987,10989380,10989533],"length":1,"stats":{"Line":2}},{"line":293,"address":[10989613,10989592,10989688,10989955],"length":1,"stats":{"Line":2}},{"line":294,"address":[10989703],"length":1,"stats":{"Line":1}},{"line":295,"address":[10990009,10989813,10989733],"length":1,"stats":{"Line":1}},{"line":296,"address":[10989933,10989837,10987014,10990317,10990049],"length":1,"stats":{"Line":3}},{"line":297,"address":[10990459],"length":1,"stats":{"Line":1}},{"line":300,"address":[10990489,10990657,10990562],"length":1,"stats":{"Line":3}},{"line":301,"address":[10868191,10868235,10868213,10868270,10868289,10868349],"length":1,"stats":{"Line":4}},{"line":303,"address":[10904185,10904086],"length":1,"stats":{"Line":5}},{"line":304,"address":[10947538,10947520],"length":1,"stats":{"Line":3}},{"line":305,"address":[10945807,10946030,10945869,10945947],"length":1,"stats":{"Line":3}},{"line":306,"address":[10946311],"length":1,"stats":{"Line":1}},{"line":307,"address":[10946370],"length":1,"stats":{"Line":1}},{"line":309,"address":[10946690,10946765,10946402,10947133,10946994],"length":1,"stats":{"Line":5}},{"line":310,"address":[10946433,10946426,10946532,10946649,10946808,10946722,10946836],"length":1,"stats":{"Line":2}},{"line":311,"address":[10862237],"length":1,"stats":{"Line":3}},{"line":312,"address":[10947261],"length":1,"stats":{"Line":1}},{"line":314,"address":[10947364,10947291],"length":1,"stats":{"Line":3}},{"line":315,"address":[10947465,10947315,10947557],"length":1,"stats":{"Line":3}},{"line":317,"address":[10911148,10911046],"length":1,"stats":{"Line":6}},{"line":318,"address":[11004912,11004930],"length":1,"stats":{"Line":3}},{"line":319,"address":[11000919,11001062,11000819,11001157],"length":1,"stats":{"Line":3}},{"line":320,"address":[11000940,11001616,11001541,11001461],"length":1,"stats":{"Line":3}},{"line":322,"address":[11002140],"length":1,"stats":{"Line":1}},{"line":323,"address":[11001895,11001961],"length":1,"stats":{"Line":2}},{"line":324,"address":[11001969,11002052],"length":1,"stats":{"Line":2}},{"line":325,"address":[11002060],"length":1,"stats":{"Line":1}},{"line":326,"address":[11002000,11001926,11002091,11002203,11002373],"length":1,"stats":{"Line":1}},{"line":327,"address":[10869469],"length":1,"stats":{"Line":2}},{"line":329,"address":[11002752],"length":1,"stats":{"Line":1}},{"line":330,"address":[11002889,11002818],"length":1,"stats":{"Line":2}},{"line":332,"address":[11002901],"length":1,"stats":{"Line":1}},{"line":333,"address":[11003119,11002981],"length":1,"stats":{"Line":2}},{"line":335,"address":[11003833,11003153,11003707,11004122,11004264],"length":1,"stats":{"Line":5}},{"line":336,"address":[11003739],"length":1,"stats":{"Line":1}},{"line":337,"address":[11003189,11003458,11003597],"length":1,"stats":{"Line":3}},{"line":338,"address":[11003231],"length":1,"stats":{"Line":1}},{"line":339,"address":[11003466,11003196,11003282,11003920,11003313],"length":1,"stats":{"Line":2}},{"line":340,"address":[11003888,11003525,11003621,11003546],"length":1,"stats":{"Line":2}},{"line":341,"address":[11003636],"length":1,"stats":{"Line":1}},{"line":342,"address":[11003746,11003666,11003942],"length":1,"stats":{"Line":1}},{"line":343,"address":[11000982,11003866,11003982,11004250,11003770],"length":1,"stats":{"Line":3}},{"line":344,"address":[11004392],"length":1,"stats":{"Line":1}},{"line":347,"address":[11004495,11004422],"length":1,"stats":{"Line":2}},{"line":348,"address":[10911271,10911179],"length":1,"stats":{"Line":2}},{"line":349,"address":[11004503],"length":1,"stats":{"Line":1}},{"line":350,"address":[11004591,11004539],"length":1,"stats":{"Line":2}},{"line":351,"address":[11004597],"length":1,"stats":{"Line":1}},{"line":354,"address":[10911290,10911198],"length":1,"stats":{"Line":2}},{"line":355,"address":[10911111,10911448],"length":1,"stats":{"Line":4}},{"line":357,"address":[10962653,10963583,10962801,10962943,10962907,10962865,10962886,10962844,10963071,10962592,10967781],"length":1,"stats":{"Line":8}},{"line":358,"address":[10907763,10907757,10907312],"length":1,"stats":{"Line":3}},{"line":359,"address":[10962710,10962831,10963001,10963105],"length":1,"stats":{"Line":2}},{"line":360,"address":[10864676],"length":1,"stats":{"Line":3}},{"line":362,"address":[10964133],"length":1,"stats":{"Line":1}},{"line":363,"address":[10963954,10963888],"length":1,"stats":{"Line":2}},{"line":364,"address":[10963962,10964045],"length":1,"stats":{"Line":2}},{"line":365,"address":[10964053],"length":1,"stats":{"Line":1}},{"line":366,"address":[10964084,10964196,10963993,10963919,10964375],"length":1,"stats":{"Line":1}},{"line":367,"address":[10864697],"length":1,"stats":{"Line":3}},{"line":369,"address":[10964758],"length":1,"stats":{"Line":1}},{"line":370,"address":[10964819,10962894,10965015,10964917],"length":1,"stats":{"Line":3}},{"line":372,"address":[10965713,10965386,10965482,10965659],"length":1,"stats":{"Line":4}},{"line":373,"address":[10965506,10965675,10966914,10965729,10965613,10965429],"length":1,"stats":{"Line":3}},{"line":374,"address":[10965737,10965811],"length":1,"stats":{"Line":2}},{"line":376,"address":[10965826],"length":1,"stats":{"Line":1}},{"line":377,"address":[10965909,10966047],"length":1,"stats":{"Line":2}},{"line":379,"address":[10966644,10966773,10966084,10967113,10967255],"length":1,"stats":{"Line":5}},{"line":380,"address":[10966676],"length":1,"stats":{"Line":1}},{"line":381,"address":[10966123,10966534,10966395],"length":1,"stats":{"Line":3}},{"line":382,"address":[10966165],"length":1,"stats":{"Line":1}},{"line":383,"address":[10966403,10966216,10966250,10966860,10966130],"length":1,"stats":{"Line":2}},{"line":384,"address":[10966828,10966462,10966483,10966558],"length":1,"stats":{"Line":2}},{"line":385,"address":[10966573],"length":1,"stats":{"Line":1}},{"line":386,"address":[10966603,10966683,10966882],"length":1,"stats":{"Line":1}},{"line":387,"address":[10864739],"length":1,"stats":{"Line":3}},{"line":388,"address":[10967383],"length":1,"stats":{"Line":1}},{"line":391,"address":[10907467],"length":1,"stats":{"Line":4}},{"line":392,"address":[10907736,10907399],"length":1,"stats":{"Line":4}},{"line":394,"address":[10905478,10905580],"length":1,"stats":{"Line":7}},{"line":395,"address":[10954304,10954322],"length":1,"stats":{"Line":3}},{"line":396,"address":[10950423,10950523,10950749,10950663],"length":1,"stats":{"Line":3}},{"line":397,"address":[10950544,10951121,10951044,10951187],"length":1,"stats":{"Line":3}},{"line":399,"address":[10951702],"length":1,"stats":{"Line":1}},{"line":400,"address":[10951460,10951526],"length":1,"stats":{"Line":2}},{"line":401,"address":[10951617,10951534],"length":1,"stats":{"Line":2}},{"line":402,"address":[10951625],"length":1,"stats":{"Line":1}},{"line":403,"address":[10951656,10951491,10951565,10951762,10951920],"length":1,"stats":{"Line":1}},{"line":404,"address":[10863181],"length":1,"stats":{"Line":3}},{"line":406,"address":[10952287],"length":1,"stats":{"Line":1}},{"line":407,"address":[10952418,10952353],"length":1,"stats":{"Line":2}},{"line":409,"address":[10952430],"length":1,"stats":{"Line":1}},{"line":410,"address":[10952636,10952504],"length":1,"stats":{"Line":2}},{"line":412,"address":[10953615,10952667,10953757,10953338,10953218],"length":1,"stats":{"Line":5}},{"line":413,"address":[10953250],"length":1,"stats":{"Line":1}},{"line":414,"address":[10952969,10952703,10953108],"length":1,"stats":{"Line":3}},{"line":415,"address":[10952745],"length":1,"stats":{"Line":1}},{"line":416,"address":[10952824,10952977,10952710,10952793,10953422],"length":1,"stats":{"Line":2}},{"line":417,"address":[10953390,10953057,10953132,10953036],"length":1,"stats":{"Line":2}},{"line":418,"address":[10953147],"length":1,"stats":{"Line":1}},{"line":419,"address":[10953177,10953444,10953257],"length":1,"stats":{"Line":1}},{"line":420,"address":[10953481,10953281,10953368,10950586,10953743],"length":1,"stats":{"Line":3}},{"line":421,"address":[10953885],"length":1,"stats":{"Line":1}},{"line":424,"address":[10905611],"length":1,"stats":{"Line":4}},{"line":425,"address":[10863387,10863365,10863501,10863422,10863441,10863343],"length":1,"stats":{"Line":4}},{"line":427,"address":[10913366,10913468],"length":1,"stats":{"Line":7}},{"line":428,"address":[11023378,11023360],"length":1,"stats":{"Line":3}},{"line":429,"address":[11018182,11018303,11018577,11018473],"length":1,"stats":{"Line":3}},{"line":430,"address":[11018902,11018985,11019069,11018324],"length":1,"stats":{"Line":3}},{"line":432,"address":[11019605],"length":1,"stats":{"Line":1}},{"line":433,"address":[11019360,11019426],"length":1,"stats":{"Line":2}},{"line":434,"address":[11019434,11019517],"length":1,"stats":{"Line":2}},{"line":435,"address":[11019525],"length":1,"stats":{"Line":1}},{"line":436,"address":[11019465,11019668,11019847,11019391,11019556],"length":1,"stats":{"Line":1}},{"line":437,"address":[10871753],"length":1,"stats":{"Line":2}},{"line":439,"address":[11020230],"length":1,"stats":{"Line":1}},{"line":440,"address":[10871774],"length":1,"stats":{"Line":3}},{"line":442,"address":[11020858,11021185,11020954,11021131],"length":1,"stats":{"Line":4}},{"line":443,"address":[11020901,11021147,11021201,11022386,11020978,11021085],"length":1,"stats":{"Line":3}},{"line":444,"address":[11021283,11021209],"length":1,"stats":{"Line":2}},{"line":446,"address":[11021298],"length":1,"stats":{"Line":1}},{"line":447,"address":[11021519,11021381],"length":1,"stats":{"Line":2}},{"line":449,"address":[11022727,11022245,11022116,11022585,11021556],"length":1,"stats":{"Line":5}},{"line":450,"address":[11022148],"length":1,"stats":{"Line":1}},{"line":451,"address":[11021595,11021867,11022006],"length":1,"stats":{"Line":3}},{"line":452,"address":[11021637],"length":1,"stats":{"Line":1}},{"line":453,"address":[11021602,11022332,11021875,11021722,11021688],"length":1,"stats":{"Line":2}},{"line":454,"address":[11021934,11021955,11022030,11022300],"length":1,"stats":{"Line":2}},{"line":455,"address":[11022045],"length":1,"stats":{"Line":1}},{"line":456,"address":[11022155,11022075,11022354],"length":1,"stats":{"Line":1}},{"line":457,"address":[11022442,11018387,11022179,11022713,11022278],"length":1,"stats":{"Line":3}},{"line":458,"address":[11022855],"length":1,"stats":{"Line":1}},{"line":461,"address":[10913499],"length":1,"stats":{"Line":3}},{"line":462,"address":[10871973,10872073,10872111,10872044,10871951,10872015,10872133,10872193],"length":1,"stats":{"Line":4}},{"line":464,"address":[10914396,10914294],"length":1,"stats":{"Line":8}},{"line":465,"address":[10914717,10914723,10914272],"length":1,"stats":{"Line":3}},{"line":466,"address":[11027679,11027558,11027849,11027953],"length":1,"stats":{"Line":3}},{"line":467,"address":[10872980],"length":1,"stats":{"Line":3}},{"line":469,"address":[11028981],"length":1,"stats":{"Line":1}},{"line":470,"address":[11028802,11028736],"length":1,"stats":{"Line":2}},{"line":471,"address":[11028893,11028810],"length":1,"stats":{"Line":2}},{"line":472,"address":[11028901],"length":1,"stats":{"Line":1}},{"line":473,"address":[11029223,11028767,11028841,11029044,11028932],"length":1,"stats":{"Line":1}},{"line":474,"address":[10873001],"length":1,"stats":{"Line":3}},{"line":476,"address":[11029606],"length":1,"stats":{"Line":1}},{"line":477,"address":[10873022],"length":1,"stats":{"Line":3}},{"line":479,"address":[11030561,11030234,11030507,11030330],"length":1,"stats":{"Line":4}},{"line":480,"address":[11031797,11030577,11030277,11030461,11030354,11030523],"length":1,"stats":{"Line":3}},{"line":481,"address":[11030585,11030659],"length":1,"stats":{"Line":2}},{"line":483,"address":[11030674],"length":1,"stats":{"Line":1}},{"line":484,"address":[11030757,11030895],"length":1,"stats":{"Line":2}},{"line":486,"address":[11030932,11031656,11031996,11032138,11031527],"length":1,"stats":{"Line":5}},{"line":487,"address":[11031559],"length":1,"stats":{"Line":1}},{"line":488,"address":[11030971,11031278,11031417],"length":1,"stats":{"Line":3}},{"line":489,"address":[11031013],"length":1,"stats":{"Line":1}},{"line":490,"address":[11031048],"length":1,"stats":{"Line":1}},{"line":491,"address":[11031133,11031099,11030978,11031286,11031743],"length":1,"stats":{"Line":2}},{"line":492,"address":[11031441,11031345,11031711,11031366],"length":1,"stats":{"Line":2}},{"line":493,"address":[11031456],"length":1,"stats":{"Line":1}},{"line":494,"address":[11031566,11031765,11031486],"length":1,"stats":{"Line":1}},{"line":495,"address":[10873043],"length":1,"stats":{"Line":3}},{"line":496,"address":[11032266],"length":1,"stats":{"Line":1}},{"line":499,"address":[10914427],"length":1,"stats":{"Line":3}},{"line":500,"address":[11029700,11031827,11029094,11032805,11030844,11028305,11030712,11032545,11031605,11032320,11027784,11032696,11032437,11030612],"length":1,"stats":{"Line":4}},{"line":502,"address":[10907798,10907900],"length":1,"stats":{"Line":8}},{"line":503,"address":[10908221,10908227,10907776],"length":1,"stats":{"Line":3}},{"line":504,"address":[10968345,10968175,10968449,10968054],"length":1,"stats":{"Line":3}},{"line":505,"address":[10968941,10968196,10968774,10968857],"length":1,"stats":{"Line":3}},{"line":507,"address":[10969477],"length":1,"stats":{"Line":1}},{"line":508,"address":[10969232,10969298],"length":1,"stats":{"Line":2}},{"line":509,"address":[10969389,10969306],"length":1,"stats":{"Line":2}},{"line":510,"address":[10969397],"length":1,"stats":{"Line":1}},{"line":511,"address":[10969719,10969263,10969428,10969337,10969540],"length":1,"stats":{"Line":1}},{"line":512,"address":[10865545],"length":1,"stats":{"Line":3}},{"line":514,"address":[10970102],"length":1,"stats":{"Line":1}},{"line":515,"address":[10865566],"length":1,"stats":{"Line":3}},{"line":517,"address":[10970826,10970730,10971003,10971057],"length":1,"stats":{"Line":4}},{"line":518,"address":[10971073,10972258,10970773,10970850,10971019,10970957],"length":1,"stats":{"Line":3}},{"line":519,"address":[10971155,10971081],"length":1,"stats":{"Line":2}},{"line":521,"address":[10971170],"length":1,"stats":{"Line":1}},{"line":522,"address":[10971253,10971391],"length":1,"stats":{"Line":2}},{"line":524,"address":[10971428,10972457,10971988,10972599,10972117],"length":1,"stats":{"Line":5}},{"line":525,"address":[10972020],"length":1,"stats":{"Line":1}},{"line":526,"address":[10971467,10971739,10971878],"length":1,"stats":{"Line":3}},{"line":527,"address":[10971509],"length":1,"stats":{"Line":1}},{"line":528,"address":[10971560,10971594,10971747,10971474,10972204],"length":1,"stats":{"Line":2}},{"line":529,"address":[10971827,10971902,10972172,10971806],"length":1,"stats":{"Line":2}},{"line":530,"address":[10971917],"length":1,"stats":{"Line":1}},{"line":531,"address":[10972027,10972226,10971947],"length":1,"stats":{"Line":1}},{"line":532,"address":[10972051,10972150,10972314,10972585,10968259],"length":1,"stats":{"Line":3}},{"line":533,"address":[10972727],"length":1,"stats":{"Line":1}},{"line":536,"address":[10907931],"length":1,"stats":{"Line":4}},{"line":537,"address":[10865807,10865743,10865903,10865925,10865865,10865985,10865765,10865836],"length":1,"stats":{"Line":4}},{"line":539,"address":[10908726,10908828],"length":1,"stats":{"Line":7}},{"line":540,"address":[10982674,10982656],"length":1,"stats":{"Line":3}},{"line":541,"address":[10977382,10977503,10977777,10977673],"length":1,"stats":{"Line":3}},{"line":542,"address":[10978102,10977524,10978185,10978269],"length":1,"stats":{"Line":3}},{"line":544,"address":[10978805],"length":1,"stats":{"Line":1}},{"line":545,"address":[10978560,10978626],"length":1,"stats":{"Line":2}},{"line":546,"address":[10978717,10978634],"length":1,"stats":{"Line":2}},{"line":547,"address":[10978725],"length":1,"stats":{"Line":1}},{"line":548,"address":[10978665,10978591,10978868,10978756,10979047],"length":1,"stats":{"Line":1}},{"line":549,"address":[10866793],"length":1,"stats":{"Line":2}},{"line":551,"address":[10979430],"length":1,"stats":{"Line":1}},{"line":552,"address":[10979491,10979601,10979699,10977566],"length":1,"stats":{"Line":3}},{"line":554,"address":[10980166,10980343,10980070,10980397],"length":1,"stats":{"Line":4}},{"line":555,"address":[10980190,10980359,10981598,10980113,10980413,10980297],"length":1,"stats":{"Line":3}},{"line":556,"address":[10980421,10980495],"length":1,"stats":{"Line":2}},{"line":558,"address":[10980510],"length":1,"stats":{"Line":1}},{"line":559,"address":[10980731,10980593],"length":1,"stats":{"Line":2}},{"line":561,"address":[10981457,10981939,10981328,10980768,10981797],"length":1,"stats":{"Line":5}},{"line":562,"address":[10981360],"length":1,"stats":{"Line":1}},{"line":563,"address":[10981218,10981079,10980807],"length":1,"stats":{"Line":3}},{"line":564,"address":[10980849],"length":1,"stats":{"Line":1}},{"line":565,"address":[10980900,10981087,10981544,10980934,10980814],"length":1,"stats":{"Line":2}},{"line":566,"address":[10981512,10981242,10981146,10981167],"length":1,"stats":{"Line":2}},{"line":567,"address":[10981257],"length":1,"stats":{"Line":1}},{"line":568,"address":[10981287,10981566,10981367],"length":1,"stats":{"Line":1}},{"line":569,"address":[10866835],"length":1,"stats":{"Line":3}},{"line":570,"address":[10982067],"length":1,"stats":{"Line":1}},{"line":573,"address":[10908859],"length":1,"stats":{"Line":4}},{"line":574,"address":[10909128,10908791],"length":1,"stats":{"Line":4}},{"line":576,"address":[10997614,10997935,10997765,11000567,10998565,10997819,10997786,10997568,10997722],"length":1,"stats":{"Line":6}},{"line":577,"address":[10911008,10910560,10911002],"length":1,"stats":{"Line":3}},{"line":578,"address":[10997752,10997874,10997969,10997668],"length":1,"stats":{"Line":3}},{"line":579,"address":[10998295,10998367],"length":1,"stats":{"Line":2}},{"line":580,"address":[10869102],"length":1,"stats":{"Line":3}},{"line":582,"address":[10999206,10999260,10998938,10999032],"length":1,"stats":{"Line":4}},{"line":583,"address":[10999160,10999222,10998979,10999056,10999827,10999276],"length":1,"stats":{"Line":3}},{"line":584,"address":[10999284,10999353],"length":1,"stats":{"Line":2}},{"line":586,"address":[11000133,10999994,10999727,10999361,10999649],"length":1,"stats":{"Line":5}},{"line":587,"address":[10999681],"length":1,"stats":{"Line":1}},{"line":588,"address":[10999539,10999385],"length":1,"stats":{"Line":2}},{"line":589,"address":[10999427],"length":1,"stats":{"Line":1}},{"line":590,"address":[10999470,10999563,10999773,10999491,10999392],"length":1,"stats":{"Line":2}},{"line":591,"address":[10999578],"length":1,"stats":{"Line":1}},{"line":592,"address":[10999688,10999795,10999608],"length":1,"stats":{"Line":1}},{"line":593,"address":[10997794,10999712,10999857,10999757,11000122],"length":1,"stats":{"Line":3}},{"line":594,"address":[11000261],"length":1,"stats":{"Line":1}},{"line":597,"address":[10910712],"length":1,"stats":{"Line":4}},{"line":598,"address":[10910644,10910981],"length":1,"stats":{"Line":4}},{"line":600,"address":[10912076,10911974],"length":1,"stats":{"Line":7}},{"line":601,"address":[10912397,10911952,10912403],"length":1,"stats":{"Line":3}},{"line":602,"address":[11009383,11009469,11009243,11009143],"length":1,"stats":{"Line":3}},{"line":603,"address":[11009764,11009264,11009841,11009907],"length":1,"stats":{"Line":3}},{"line":605,"address":[11010422],"length":1,"stats":{"Line":1}},{"line":606,"address":[11010246,11010180],"length":1,"stats":{"Line":2}},{"line":607,"address":[11010337,11010254],"length":1,"stats":{"Line":2}},{"line":608,"address":[11010345],"length":1,"stats":{"Line":1}},{"line":609,"address":[11010376,11010211,11010482,11010640,11010285],"length":1,"stats":{"Line":1}},{"line":610,"address":[10870557],"length":1,"stats":{"Line":3}},{"line":612,"address":[11011007],"length":1,"stats":{"Line":1}},{"line":613,"address":[11011138,11011073],"length":1,"stats":{"Line":2}},{"line":615,"address":[11011150],"length":1,"stats":{"Line":1}},{"line":616,"address":[11011356,11011224],"length":1,"stats":{"Line":2}},{"line":618,"address":[11012477,11011938,11012058,11011387,11012335],"length":1,"stats":{"Line":5}},{"line":619,"address":[11011970],"length":1,"stats":{"Line":1}},{"line":620,"address":[11011689,11011423,11011828],"length":1,"stats":{"Line":3}},{"line":621,"address":[11011465],"length":1,"stats":{"Line":1}},{"line":622,"address":[11011513,11011430,11011544,11011697,11012142],"length":1,"stats":{"Line":2}},{"line":623,"address":[11011756,11011777,11011852,11012110],"length":1,"stats":{"Line":2}},{"line":624,"address":[11011867],"length":1,"stats":{"Line":1}},{"line":625,"address":[11012164,11011897,11011977],"length":1,"stats":{"Line":1}},{"line":626,"address":[11012088,11012001,11012201,11012463,11009306],"length":1,"stats":{"Line":3}},{"line":627,"address":[11012605],"length":1,"stats":{"Line":1}},{"line":630,"address":[11012708,11012635,11012800],"length":1,"stats":{"Line":4}},{"line":631,"address":[10870798,10870741,10870877,10870817,10870719,10870763],"length":1,"stats":{"Line":4}},{"line":633,"address":[10914758,10914860],"length":1,"stats":{"Line":7}},{"line":634,"address":[10915187,10915181,10914736],"length":1,"stats":{"Line":3}},{"line":635,"address":[11033293,11032967,11033067,11033207],"length":1,"stats":{"Line":3}},{"line":636,"address":[10873678],"length":1,"stats":{"Line":3}},{"line":638,"address":[11034246],"length":1,"stats":{"Line":1}},{"line":639,"address":[11034070,11034004],"length":1,"stats":{"Line":2}},{"line":640,"address":[11034078,11034161],"length":1,"stats":{"Line":2}},{"line":641,"address":[11034169],"length":1,"stats":{"Line":1}},{"line":642,"address":[11034306,11034035,11034200,11034464,11034109],"length":1,"stats":{"Line":1}},{"line":643,"address":[10873693],"length":1,"stats":{"Line":3}},{"line":645,"address":[11034831],"length":1,"stats":{"Line":1}},{"line":646,"address":[11034962,11034897],"length":1,"stats":{"Line":2}},{"line":648,"address":[11034974],"length":1,"stats":{"Line":1}},{"line":649,"address":[11035048,11035180],"length":1,"stats":{"Line":2}},{"line":651,"address":[11036159,11035762,11036301,11035211,11035882],"length":1,"stats":{"Line":5}},{"line":652,"address":[11035794],"length":1,"stats":{"Line":1}},{"line":653,"address":[11035513,11035652,11035247],"length":1,"stats":{"Line":3}},{"line":654,"address":[11035289],"length":1,"stats":{"Line":1}},{"line":655,"address":[11035254,11035521,11035337,11035966,11035368],"length":1,"stats":{"Line":2}},{"line":656,"address":[11035676,11035934,11035580,11035601],"length":1,"stats":{"Line":2}},{"line":657,"address":[11035691],"length":1,"stats":{"Line":1}},{"line":658,"address":[11035801,11035988,11035721],"length":1,"stats":{"Line":1}},{"line":659,"address":[10873711],"length":1,"stats":{"Line":3}},{"line":660,"address":[11036429],"length":1,"stats":{"Line":1}},{"line":663,"address":[11036532,11036459,11036624],"length":1,"stats":{"Line":4}},{"line":664,"address":[11033148,11033612,11035837,11032853,11034921,11036600,11036705,11034353,11035009,11036483,11035132,11036805],"length":1,"stats":{"Line":4}},{"line":666,"address":[11023624,11023696,11023408,11024264,11027320,11023581,11023645,11023666,11023806,11023466],"length":1,"stats":{"Line":7}},{"line":667,"address":[11027410,11027392],"length":1,"stats":{"Line":3}},{"line":668,"address":[11023511,11023751,11023837,11023611],"length":1,"stats":{"Line":3}},{"line":669,"address":[10872430],"length":1,"stats":{"Line":3}},{"line":671,"address":[11024790],"length":1,"stats":{"Line":1}},{"line":672,"address":[11024614,11024548],"length":1,"stats":{"Line":2}},{"line":673,"address":[11024622,11024705],"length":1,"stats":{"Line":2}},{"line":674,"address":[11024713],"length":1,"stats":{"Line":1}},{"line":675,"address":[11024653,11024744,11024579,11024850,11025008],"length":1,"stats":{"Line":1}},{"line":676,"address":[10872445],"length":1,"stats":{"Line":3}},{"line":678,"address":[11025375],"length":1,"stats":{"Line":1}},{"line":679,"address":[11025506,11025441],"length":1,"stats":{"Line":2}},{"line":681,"address":[11025518],"length":1,"stats":{"Line":1}},{"line":682,"address":[11025592,11025724],"length":1,"stats":{"Line":2}},{"line":684,"address":[11026426,11026845,11026306,11026703,11025755],"length":1,"stats":{"Line":5}},{"line":685,"address":[11026338],"length":1,"stats":{"Line":1}},{"line":686,"address":[11025791,11026057,11026196],"length":1,"stats":{"Line":3}},{"line":687,"address":[11025833],"length":1,"stats":{"Line":1}},{"line":688,"address":[11025881,11026510,11025912,11025798,11026065],"length":1,"stats":{"Line":2}},{"line":689,"address":[11026124,11026478,11026145,11026220],"length":1,"stats":{"Line":2}},{"line":690,"address":[11026235],"length":1,"stats":{"Line":1}},{"line":691,"address":[11026345,11026265,11026532],"length":1,"stats":{"Line":1}},{"line":692,"address":[10872463],"length":1,"stats":{"Line":3}},{"line":693,"address":[11026973],"length":1,"stats":{"Line":1}},{"line":696,"address":[11027076,11027168,11027003],"length":1,"stats":{"Line":3}},{"line":697,"address":[11027027,11025553,11027349,11027144,11024897,11026381,11023692,11027249,11027429,11025676,11025465,11024156],"length":1,"stats":{"Line":4}},{"line":699,"address":[11040808,11037184,11036896,11037154,11037069,11037112,11037133,11036954,11037752,11037294],"length":1,"stats":{"Line":7}},{"line":700,"address":[10915200,10915645,10915651],"length":1,"stats":{"Line":3}},{"line":701,"address":[11036999,11037239,11037099,11037325],"length":1,"stats":{"Line":3}},{"line":702,"address":[10858174],"length":1,"stats":{"Line":3}},{"line":704,"address":[11038278],"length":1,"stats":{"Line":1}},{"line":705,"address":[11038036,11038102],"length":1,"stats":{"Line":2}},{"line":706,"address":[11038193,11038110],"length":1,"stats":{"Line":2}},{"line":707,"address":[11038201],"length":1,"stats":{"Line":1}},{"line":708,"address":[11038338,11038141,11038067,11038496,11038232],"length":1,"stats":{"Line":1}},{"line":709,"address":[11038354,11038441,11038585,11037141],"length":1,"stats":{"Line":3}},{"line":711,"address":[11038863],"length":1,"stats":{"Line":1}},{"line":712,"address":[11038929,11038994],"length":1,"stats":{"Line":2}},{"line":714,"address":[11039006],"length":1,"stats":{"Line":1}},{"line":715,"address":[11039212,11039080],"length":1,"stats":{"Line":2}},{"line":717,"address":[11040191,11039914,11040333,11039794,11039243],"length":1,"stats":{"Line":5}},{"line":718,"address":[11039826],"length":1,"stats":{"Line":1}},{"line":719,"address":[11039545,11039279,11039684],"length":1,"stats":{"Line":3}},{"line":720,"address":[11039321],"length":1,"stats":{"Line":1}},{"line":721,"address":[11039553,11039286,11039369,11039400,11039998],"length":1,"stats":{"Line":2}},{"line":722,"address":[11039966,11039633,11039708,11039612],"length":1,"stats":{"Line":2}},{"line":723,"address":[11039723],"length":1,"stats":{"Line":1}},{"line":724,"address":[11039833,11040020,11039753],"length":1,"stats":{"Line":1}},{"line":725,"address":[11037162,11039944,11040057,11040319,11039857],"length":1,"stats":{"Line":3}},{"line":726,"address":[11040461],"length":1,"stats":{"Line":1}},{"line":729,"address":[11040564,11040656,11040491],"length":1,"stats":{"Line":3}},{"line":730,"address":[11040632,11039041,11040515,11036885,11038385,11037180,11040737,11039869,11037644,11038953,11039164,11040837],"length":1,"stats":{"Line":4}},{"line":732,"address":[10982992,10982941,10983102,10982704,10982762,10983560,10982962,10982877,10986616,10982920],"length":1,"stats":{"Line":7}},{"line":733,"address":[10986688,10986706],"length":1,"stats":{"Line":3}},{"line":734,"address":[10982807,10982907,10983047,10983133],"length":1,"stats":{"Line":3}},{"line":735,"address":[10983505,10983428,10983571,10982928],"length":1,"stats":{"Line":3}},{"line":737,"address":[10984086],"length":1,"stats":{"Line":1}},{"line":738,"address":[10983844,10983910],"length":1,"stats":{"Line":2}},{"line":739,"address":[10983918,10984001],"length":1,"stats":{"Line":2}},{"line":740,"address":[10984009],"length":1,"stats":{"Line":1}},{"line":741,"address":[10983875,10984040,10984146,10983949,10984304],"length":1,"stats":{"Line":1}},{"line":742,"address":[10984393,10984162,10984249,10982949],"length":1,"stats":{"Line":3}},{"line":744,"address":[10984671],"length":1,"stats":{"Line":1}},{"line":745,"address":[10984802,10984737],"length":1,"stats":{"Line":2}},{"line":747,"address":[10984814],"length":1,"stats":{"Line":1}},{"line":748,"address":[10984888,10985020],"length":1,"stats":{"Line":2}},{"line":750,"address":[10985602,10985051,10985999,10986141,10985722],"length":1,"stats":{"Line":5}},{"line":751,"address":[10985634],"length":1,"stats":{"Line":1}},{"line":752,"address":[10985087,10985353,10985492],"length":1,"stats":{"Line":3}},{"line":753,"address":[10985129],"length":1,"stats":{"Line":1}},{"line":754,"address":[10985177,10985361,10985094,10985208,10985806],"length":1,"stats":{"Line":2}},{"line":755,"address":[10985420,10985441,10985516,10985774],"length":1,"stats":{"Line":2}},{"line":756,"address":[10985531],"length":1,"stats":{"Line":1}},{"line":757,"address":[10985828,10985561,10985641],"length":1,"stats":{"Line":1}},{"line":758,"address":[10982970,10985865,10986127,10985665,10985752],"length":1,"stats":{"Line":3}},{"line":759,"address":[10986269],"length":1,"stats":{"Line":1}},{"line":762,"address":[10909323],"length":1,"stats":{"Line":4}},{"line":763,"address":[10985677,10982988,10984972,10986323,10986545,10986645,10984193,10983452,10986440,10984761,10986725,10984849],"length":1,"stats":{"Line":4}},{"line":765,"address":[10911612,10911510],"length":1,"stats":{"Line":6}},{"line":766,"address":[11004960,11004978],"length":1,"stats":{"Line":3}},{"line":767,"address":[11005211,11005111,11005351,11005437],"length":1,"stats":{"Line":3}},{"line":768,"address":[10869998],"length":1,"stats":{"Line":3}},{"line":770,"address":[11006390],"length":1,"stats":{"Line":1}},{"line":771,"address":[11006214,11006148],"length":1,"stats":{"Line":2}},{"line":772,"address":[11006305,11006222],"length":1,"stats":{"Line":2}},{"line":773,"address":[11006313],"length":1,"stats":{"Line":1}},{"line":774,"address":[11006253,11006450,11006608,11006179,11006344],"length":1,"stats":{"Line":1}},{"line":775,"address":[10870013],"length":1,"stats":{"Line":2}},{"line":777,"address":[11006975],"length":1,"stats":{"Line":1}},{"line":778,"address":[11007106,11007041],"length":1,"stats":{"Line":2}},{"line":780,"address":[11007118],"length":1,"stats":{"Line":1}},{"line":781,"address":[11007192,11007324],"length":1,"stats":{"Line":2}},{"line":783,"address":[11008445,11007355,11008026,11007906,11008303],"length":1,"stats":{"Line":5}},{"line":784,"address":[11007938],"length":1,"stats":{"Line":1}},{"line":785,"address":[11007391,11007657,11007796],"length":1,"stats":{"Line":3}},{"line":786,"address":[11007433],"length":1,"stats":{"Line":1}},{"line":787,"address":[11007481,11007665,11008110,11007398,11007512],"length":1,"stats":{"Line":2}},{"line":788,"address":[11007820,11007745,11008078,11007724],"length":1,"stats":{"Line":2}},{"line":789,"address":[11007835],"length":1,"stats":{"Line":1}},{"line":790,"address":[11007945,11007865,11008132],"length":1,"stats":{"Line":1}},{"line":791,"address":[10870031],"length":1,"stats":{"Line":3}},{"line":792,"address":[11008573],"length":1,"stats":{"Line":1}},{"line":795,"address":[11008676,11008603,11008768],"length":1,"stats":{"Line":4}},{"line":796,"address":[11008949,11007065,11007276,11006497,11004997,11007153,11005756,11005292,11007981,11008627,11008744,11008849],"length":1,"stats":{"Line":4}},{"line":798,"address":[10908262,10908364],"length":1,"stats":{"Line":7}},{"line":799,"address":[10908685,10908691,10908240],"length":1,"stats":{"Line":3}},{"line":800,"address":[10973709,10973483,10973623,10973383],"length":1,"stats":{"Line":3}},{"line":801,"address":[10866222],"length":1,"stats":{"Line":3}},{"line":803,"address":[10974662],"length":1,"stats":{"Line":1}},{"line":804,"address":[10974420,10974486],"length":1,"stats":{"Line":2}},{"line":805,"address":[10974494,10974577],"length":1,"stats":{"Line":2}},{"line":806,"address":[10974585],"length":1,"stats":{"Line":1}},{"line":807,"address":[10974525,10974616,10974722,10974880,10974451],"length":1,"stats":{"Line":1}},{"line":808,"address":[10866237],"length":1,"stats":{"Line":3}},{"line":810,"address":[10975247],"length":1,"stats":{"Line":1}},{"line":811,"address":[10975378,10975313],"length":1,"stats":{"Line":2}},{"line":813,"address":[10975390],"length":1,"stats":{"Line":1}},{"line":814,"address":[10975464,10975596],"length":1,"stats":{"Line":2}},{"line":816,"address":[10975627,10976717,10976575,10976178,10976298],"length":1,"stats":{"Line":5}},{"line":817,"address":[10976210],"length":1,"stats":{"Line":1}},{"line":818,"address":[10976068,10975663,10975929],"length":1,"stats":{"Line":3}},{"line":819,"address":[10975705],"length":1,"stats":{"Line":1}},{"line":820,"address":[10975670,10975937,10975784,10976382,10975753],"length":1,"stats":{"Line":2}},{"line":821,"address":[10976017,10976092,10975996,10976350],"length":1,"stats":{"Line":2}},{"line":822,"address":[10976107],"length":1,"stats":{"Line":1}},{"line":823,"address":[10976404,10976217,10976137],"length":1,"stats":{"Line":1}},{"line":824,"address":[10976241,10976703,10973546,10976328,10976441],"length":1,"stats":{"Line":3}},{"line":825,"address":[10976845],"length":1,"stats":{"Line":1}},{"line":828,"address":[10908395],"length":1,"stats":{"Line":3}},{"line":829,"address":[10866421,10866497,10866478,10866557,10866443,10866399],"length":1,"stats":{"Line":4}},{"line":831,"address":[10903724,10903622],"length":1,"stats":{"Line":7}},{"line":832,"address":[10904051,10903600,10904045],"length":1,"stats":{"Line":3}},{"line":833,"address":[10942125,10942039,10941799,10941899],"length":1,"stats":{"Line":3}},{"line":834,"address":[10861694],"length":1,"stats":{"Line":3}},{"line":836,"address":[10943078],"length":1,"stats":{"Line":1}},{"line":837,"address":[10942902,10942836],"length":1,"stats":{"Line":2}},{"line":838,"address":[10942993,10942910],"length":1,"stats":{"Line":2}},{"line":839,"address":[10943001],"length":1,"stats":{"Line":1}},{"line":840,"address":[10942867,10943032,10943138,10942941,10943296],"length":1,"stats":{"Line":1}},{"line":841,"address":[10861709],"length":1,"stats":{"Line":3}},{"line":843,"address":[10943663],"length":1,"stats":{"Line":1}},{"line":844,"address":[10943729,10943794],"length":1,"stats":{"Line":2}},{"line":846,"address":[10943806],"length":1,"stats":{"Line":1}},{"line":847,"address":[10944012,10943880],"length":1,"stats":{"Line":2}},{"line":849,"address":[10944043,10944714,10944991,10945133,10944594],"length":1,"stats":{"Line":5}},{"line":850,"address":[10944626],"length":1,"stats":{"Line":1}},{"line":851,"address":[10944345,10944484,10944079],"length":1,"stats":{"Line":3}},{"line":852,"address":[10944121],"length":1,"stats":{"Line":1}},{"line":853,"address":[10944200,10944086,10944169,10944353,10944798],"length":1,"stats":{"Line":2}},{"line":854,"address":[10944433,10944766,10944412,10944508],"length":1,"stats":{"Line":2}},{"line":855,"address":[10944523],"length":1,"stats":{"Line":1}},{"line":856,"address":[10944633,10944820,10944553],"length":1,"stats":{"Line":1}},{"line":857,"address":[10861727],"length":1,"stats":{"Line":3}},{"line":858,"address":[10945261],"length":1,"stats":{"Line":1}},{"line":861,"address":[10903755],"length":1,"stats":{"Line":4}},{"line":862,"address":[10862029,10861893,10861950,10861969,10861871,10861915],"length":1,"stats":{"Line":4}}],"covered":653,"coverable":659},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","dag.rs"],"content":"//! DAG (Directed Acyclic Graph) builder for job dependencies\n\nuse crate::pipeline::{JobDefinition, PipelineDefinition};\nuse gitforce_common::{JobId, PipelineRunId, Result};\nuse std::collections::{HashMap, HashSet};\n\n/// DAG node representing a job\n#[derive(Debug, Clone)]\npub struct JobNode {\n pub id: JobId,\n pub name: String,\n pub definition: JobDefinition,\n pub dependencies: Vec,\n}\n\n/// Job graph representing the pipeline DAG\n#[derive(Debug, Clone)]\npub struct JobGraph {\n pub nodes: Vec,\n pub run_id: PipelineRunId,\n}\n\nimpl JobGraph {\n /// Get nodes with no dependencies (entry points)\n pub fn entry_points(&self) -> Vec<&JobNode> {\n self.nodes\n .iter()\n .filter(|n| n.dependencies.is_empty())\n .collect()\n }\n\n /// Get a node by ID\n pub fn get(&self, id: JobId) -> Option<&JobNode> {\n self.nodes.iter().find(|n| n.id == id)\n }\n\n /// Get a node by name\n pub fn get_by_name(&self, name: &str) -> Option<&JobNode> {\n self.nodes.iter().find(|n| n.name == name)\n }\n\n /// Get nodes that depend on the given node\n pub fn dependents(&self, id: JobId) -> Vec<&JobNode> {\n self.nodes\n .iter()\n .filter(|n| n.dependencies.contains(&id))\n .collect()\n }\n\n /// Check if the graph has a cycle (invalid DAG)\n pub fn has_cycle(&self) -> bool {\n let mut visited = HashSet::new();\n let mut recursion_stack = HashSet::new();\n\n for node in &self.nodes {\n if self.detect_cycle_dfs(node.id, &mut visited, &mut recursion_stack) {\n return true;\n }\n }\n false\n }\n\n fn detect_cycle_dfs(\n &self,\n node_id: JobId,\n visited: &mut HashSet,\n recursion_stack: &mut HashSet,\n ) -> bool {\n if recursion_stack.contains(&node_id) {\n return true;\n }\n if visited.contains(&node_id) {\n return false;\n }\n\n visited.insert(node_id);\n recursion_stack.insert(node_id);\n\n if let Some(node) = self.get(node_id) {\n for dep in &node.dependencies {\n if self.detect_cycle_dfs(*dep, visited, recursion_stack) {\n return true;\n }\n }\n }\n\n recursion_stack.remove(&node_id);\n false\n }\n\n /// Get topological order of nodes\n pub fn topological_order(&self) -> Vec {\n let mut result = Vec::new();\n let mut visited = HashSet::new();\n\n for node in &self.nodes {\n self.visit(node.id, &mut visited, &mut result);\n }\n\n result\n }\n\n fn visit(&self, node_id: JobId, visited: &mut HashSet, result: &mut Vec) {\n if visited.contains(&node_id) {\n return;\n }\n\n visited.insert(node_id);\n\n // Visit dependencies first\n if let Some(node) = self.get(node_id) {\n for dep in &node.dependencies {\n self.visit(*dep, visited, result);\n }\n }\n\n result.push(node_id);\n }\n}\n\n/// DAG builder for pipeline jobs\npub struct DagBuilder;\n\nimpl DagBuilder {\n /// Build a job graph from a pipeline definition\n pub fn build(\n pipeline: &PipelineDefinition,\n run_id: PipelineRunId,\n ) -> Result {\n let mut nodes = Vec::new();\n let mut name_to_id = HashMap::new();\n\n // First pass: create all nodes\n for job in &pipeline.jobs {\n let job_id = JobId::new();\n name_to_id.insert(job.name.clone(), job_id);\n\n nodes.push(JobNode {\n id: job_id,\n name: job.name.clone(),\n definition: job.clone(),\n dependencies: Vec::new(),\n });\n }\n\n // Second pass: resolve dependencies\n for node in &mut nodes {\n let dep_ids: Vec = node\n .definition\n .needs\n .iter()\n .filter_map(|name| name_to_id.get(name).copied())\n .collect();\n\n node.dependencies = dep_ids;\n }\n\n let graph = JobGraph { nodes, run_id };\n\n // Validate no cycles\n if graph.has_cycle() {\n return Err(gitforce_common::Error::invalid_input(\n \"pipeline contains circular dependencies\",\n ));\n }\n\n Ok(graph)\n }\n\n /// Get the maximum depth of any node in the graph\n pub fn max_depth(graph: &JobGraph) -> usize {\n let mut depths: HashMap = HashMap::new();\n\n for node in &graph.nodes {\n let depth = Self::compute_depth(graph, node.id, &mut depths);\n depths.insert(node.id, depth);\n }\n\n depths.values().max().copied().unwrap_or(0)\n }\n\n fn compute_depth(\n graph: &JobGraph,\n node_id: JobId,\n depths: &mut HashMap,\n ) -> usize {\n if let Some(&depth) = depths.get(&node_id) {\n return depth;\n }\n\n let node = match graph.get(node_id) {\n Some(n) => n,\n None => return 0,\n };\n\n if node.dependencies.is_empty() {\n return 0;\n }\n\n let max_dep_depth = node\n .dependencies\n .iter()\n .map(|dep| Self::compute_depth(graph, *dep, depths))\n .max()\n .unwrap_or(0);\n\n max_dep_depth + 1\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::pipeline::StepDefinition;\n use std::collections::HashMap;\n\n fn make_job(name: &str, needs: Vec<&str>) -> JobDefinition {\n JobDefinition {\n name: name.to_string(),\n image: \"rust:latest\".to_string(),\n needs: needs.into_iter().map(|s| s.to_string()).collect(),\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step1\".to_string(),\n run: \"echo hello\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n }\n }\n\n #[test]\n fn test_build_simple_dag() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![]), make_job(\"test\", vec![\"build\"])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n assert_eq!(graph.nodes.len(), 2);\n assert!(!graph.has_cycle());\n\n let build = graph.get_by_name(\"build\").unwrap();\n assert!(build.dependencies.is_empty());\n\n let test = graph.get_by_name(\"test\").unwrap();\n assert_eq!(test.dependencies.len(), 1);\n }\n\n #[test]\n fn test_detect_cycle() {\n let jobs = vec![\n make_job(\"a\", vec![\"c\"]),\n make_job(\"b\", vec![\"a\"]),\n make_job(\"c\", vec![\"b\"]), // Creates cycle: a -> c -> b -> a\n ];\n\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs,\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let result = DagBuilder::build(&pipeline, run_id);\n // Pipeline with cycle should return error\n assert!(result.is_err());\n let err = result.unwrap_err();\n assert!(err.message.contains(\"circular\"));\n }\n\n #[test]\n fn test_topological_order() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"build\", vec![]),\n make_job(\"test\", vec![\"build\"]),\n make_job(\"deploy\", vec![\"test\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let order = graph.topological_order();\n assert_eq!(order.len(), 3);\n\n // build should come before test, test should come before deploy\n let build_idx = order.iter().position(|id| graph.get(*id).unwrap().name == \"build\").unwrap();\n let test_idx = order.iter().position(|id| graph.get(*id).unwrap().name == \"test\").unwrap();\n let deploy_idx = order.iter().position(|id| graph.get(*id).unwrap().name == \"deploy\").unwrap();\n\n assert!(build_idx < test_idx);\n assert!(test_idx < deploy_idx);\n }\n\n #[test]\n fn test_max_depth_single_job() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n assert_eq!(DagBuilder::max_depth(&graph), 0);\n }\n\n #[test]\n fn test_max_depth_nested() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"a\", vec![]),\n make_job(\"b\", vec![\"a\"]),\n make_job(\"c\", vec![\"b\"]),\n make_job(\"d\", vec![\"c\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n // Depth: a=0, b=1, c=2, d=3\n assert_eq!(DagBuilder::max_depth(&graph), 3);\n }\n\n #[test]\n fn test_get_by_name_found() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n assert!(graph.get_by_name(\"build\").is_some());\n }\n\n #[test]\n fn test_get_by_name_not_found() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n assert!(graph.get_by_name(\"nonexistent\").is_none());\n }\n\n #[test]\n fn test_dependents() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"build\", vec![]),\n make_job(\"test\", vec![\"build\"]),\n make_job(\"deploy\", vec![\"test\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n // Find the build node\n let build_id = graph.get_by_name(\"build\").unwrap().id;\n let deps = graph.dependents(build_id);\n assert_eq!(deps.len(), 1);\n assert_eq!(deps[0].name, \"test\");\n }\n\n #[test]\n fn test_dependents_none() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"build\", vec![]),\n make_job(\"test\", vec![\"build\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n // Find the deploy node (which doesn't exist as a dependency)\n let test_id = graph.get_by_name(\"test\").unwrap().id;\n let deps = graph.dependents(test_id);\n assert!(deps.is_empty());\n }\n\n #[test]\n fn test_entry_points() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"build\", vec![]),\n make_job(\"test\", vec![\"build\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let entry = graph.entry_points();\n assert_eq!(entry.len(), 1);\n assert_eq!(entry[0].name, \"build\");\n }\n\n #[test]\n fn test_multiple_entry_points() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n make_job(\"setup\", vec![]),\n make_job(\"teardown\", vec![]),\n make_job(\"test\", vec![\"setup\", \"teardown\"]),\n ],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let entries = graph.entry_points();\n assert_eq!(entries.len(), 2);\n let names: Vec<_> = entries.iter().map(|n| n.name.as_str()).collect();\n assert!(names.contains(&\"setup\"));\n assert!(names.contains(&\"teardown\"));\n }\n\n #[test]\n fn test_get_node_by_id() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let node = graph.get_by_name(\"build\").unwrap();\n let found = graph.get(node.id);\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"build\");\n }\n\n #[test]\n fn test_get_node_by_id_not_found() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![make_job(\"build\", vec![])],\n };\n\n let run_id = gitforce_common::PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let fake_id = gitforce_common::JobId::new();\n assert!(graph.get(fake_id).is_none());\n }\n}\n","traces":[{"line":25,"address":[21448064],"length":1,"stats":{"Line":2}},{"line":26,"address":[21448096],"length":1,"stats":{"Line":1}},{"line":28,"address":[2932974,2932960],"length":1,"stats":{"Line":5}},{"line":33,"address":[2895168],"length":1,"stats":{"Line":18}},{"line":34,"address":[3099264,3099281],"length":1,"stats":{"Line":26}},{"line":38,"address":[2894080],"length":1,"stats":{"Line":1}},{"line":39,"address":[3099168,3099185],"length":1,"stats":{"Line":11}},{"line":43,"address":[2893968],"length":1,"stats":{"Line":2}},{"line":44,"address":[2897813],"length":1,"stats":{"Line":2}},{"line":46,"address":[21447935],"length":1,"stats":{"Line":6}},{"line":51,"address":[2899739,2899733,2899360],"length":1,"stats":{"Line":16}},{"line":52,"address":[2895636],"length":1,"stats":{"Line":2}},{"line":53,"address":[2895665],"length":1,"stats":{"Line":16}},{"line":55,"address":[2899505,2899444],"length":1,"stats":{"Line":18}},{"line":56,"address":[2899695,2899606],"length":1,"stats":{"Line":6}},{"line":57,"address":[2895992],"length":1,"stats":{"Line":1}},{"line":60,"address":[2895933],"length":1,"stats":{"Line":6}},{"line":63,"address":[21448160],"length":1,"stats":{"Line":16}},{"line":69,"address":[21448213],"length":1,"stats":{"Line":4}},{"line":70,"address":[2894345],"length":1,"stats":{"Line":1}},{"line":72,"address":[2898104],"length":1,"stats":{"Line":18}},{"line":73,"address":[2894525],"length":1,"stats":{"Line":11}},{"line":76,"address":[2894365],"length":1,"stats":{"Line":4}},{"line":77,"address":[2894406],"length":1,"stats":{"Line":18}},{"line":79,"address":[2894453,2894535],"length":1,"stats":{"Line":22}},{"line":80,"address":[2894596,2894548],"length":1,"stats":{"Line":22}},{"line":81,"address":[2894668],"length":1,"stats":{"Line":3}},{"line":82,"address":[2898469],"length":1,"stats":{"Line":1}},{"line":87,"address":[21448487],"length":1,"stats":{"Line":4}},{"line":88,"address":[2894589],"length":1,"stats":{"Line":17}},{"line":92,"address":[21449030,21448624,21449024],"length":1,"stats":{"Line":2}},{"line":93,"address":[21448654],"length":1,"stats":{"Line":2}},{"line":94,"address":[2894780],"length":1,"stats":{"Line":2}},{"line":96,"address":[2898610,2898671],"length":1,"stats":{"Line":4}},{"line":97,"address":[2898867,2898764],"length":1,"stats":{"Line":4}},{"line":100,"address":[2895053],"length":1,"stats":{"Line":2}},{"line":103,"address":[2895232],"length":1,"stats":{"Line":2}},{"line":104,"address":[2899031],"length":1,"stats":{"Line":2}},{"line":108,"address":[2899050],"length":1,"stats":{"Line":2}},{"line":111,"address":[21449332,21449252],"length":1,"stats":{"Line":4}},{"line":112,"address":[2895515,2895441],"length":1,"stats":{"Line":4}},{"line":113,"address":[2895587],"length":1,"stats":{"Line":2}},{"line":117,"address":[2895476],"length":1,"stats":{"Line":2}},{"line":126,"address":[2892821,2891872,2893450],"length":1,"stats":{"Line":2}},{"line":130,"address":[2895849],"length":1,"stats":{"Line":13}},{"line":131,"address":[2891946],"length":1,"stats":{"Line":3}},{"line":134,"address":[2896007,2895939],"length":1,"stats":{"Line":19}},{"line":135,"address":[2896108],"length":1,"stats":{"Line":15}},{"line":136,"address":[2893082],"length":1,"stats":{"Line":4}},{"line":138,"address":[2897160],"length":1,"stats":{"Line":4}},{"line":140,"address":[2893165],"length":1,"stats":{"Line":15}},{"line":141,"address":[2893197],"length":1,"stats":{"Line":4}},{"line":142,"address":[2897116],"length":1,"stats":{"Line":15}},{"line":147,"address":[2896936,2896138],"length":1,"stats":{"Line":27}},{"line":148,"address":[2896266],"length":1,"stats":{"Line":4}},{"line":152,"address":[2892868],"length":1,"stats":{"Line":21}},{"line":155,"address":[2892927],"length":1,"stats":{"Line":4}},{"line":158,"address":[21446295],"length":1,"stats":{"Line":13}},{"line":161,"address":[2892474,2892545],"length":1,"stats":{"Line":4}},{"line":162,"address":[2896567,2896626],"length":1,"stats":{"Line":2}},{"line":167,"address":[21446463],"length":1,"stats":{"Line":15}},{"line":171,"address":[21447392,21447846,21447852],"length":1,"stats":{"Line":2}},{"line":172,"address":[2897353],"length":1,"stats":{"Line":2}},{"line":174,"address":[2893527,2893599],"length":1,"stats":{"Line":4}},{"line":175,"address":[2897533,2897702],"length":1,"stats":{"Line":4}},{"line":176,"address":[2897710],"length":1,"stats":{"Line":2}},{"line":179,"address":[2893752],"length":1,"stats":{"Line":1}},{"line":182,"address":[2895408],"length":1,"stats":{"Line":2}},{"line":187,"address":[2895453],"length":1,"stats":{"Line":2}},{"line":188,"address":[2891556],"length":1,"stats":{"Line":1}},{"line":191,"address":[2891573],"length":1,"stats":{"Line":2}},{"line":192,"address":[21445562],"length":1,"stats":{"Line":2}},{"line":193,"address":[2891695],"length":1,"stats":{"Line":0}},{"line":196,"address":[2891676],"length":1,"stats":{"Line":2}},{"line":197,"address":[2891817],"length":1,"stats":{"Line":2}},{"line":200,"address":[2891711],"length":1,"stats":{"Line":1}},{"line":203,"address":[2891749],"length":1,"stats":{"Line":3}},{"line":207,"address":[2891798,2891836,2891846],"length":1,"stats":{"Line":2}}],"covered":77,"coverable":78},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","engine.rs"],"content":"//! CI Engine - orchestrates pipeline execution\n\nuse crate::dag::{DagBuilder, JobGraph};\nuse crate::pipeline::{PipelineDefinition, PipelineTriggerEvent};\nuse crate::state::JobStateMachine;\nuse gitforce_common::{JobId, JobStatus, PipelineRunId, PipelineStatus, RepoId, Result};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// CI Engine state\n#[derive(Debug, Clone)]\npub struct CiEngineState {\n pub run_id: PipelineRunId,\n pub pipeline_id: gitforce_common::PipelineId,\n pub repo_id: RepoId,\n pub status: PipelineStatus,\n pub jobs: HashMap,\n pub started_at: Option>,\n pub finished_at: Option>,\n}\n\nimpl CiEngineState {\n pub fn new(\n run_id: PipelineRunId,\n pipeline_id: gitforce_common::PipelineId,\n repo_id: RepoId,\n ) -> Self {\n Self {\n run_id,\n pipeline_id,\n repo_id,\n status: PipelineStatus::Pending,\n jobs: HashMap::new(),\n started_at: None,\n finished_at: None,\n }\n }\n\n /// Check if all jobs are finished\n pub fn all_jobs_finished(&self) -> bool {\n self.jobs.values().all(|j| j.is_terminal())\n }\n\n /// Check if all jobs succeeded\n pub fn all_jobs_succeeded(&self) -> bool {\n self.jobs.values().all(|j| j.status() == JobStatus::Succeeded)\n }\n\n /// Get failed jobs\n pub fn failed_jobs(&self) -> Vec {\n self.jobs\n .iter()\n .filter(|(_, j)| j.status() == JobStatus::Failed)\n .map(|(id, _)| *id)\n .collect()\n }\n\n /// Get pending jobs (not yet started)\n pub fn pending_jobs(&self) -> Vec {\n self.jobs\n .iter()\n .filter(|(_, j)| {\n matches!(\n j.status(),\n JobStatus::Pending | JobStatus::Queued | JobStatus::Assigned\n )\n })\n .map(|(id, _)| *id)\n .collect()\n }\n}\n\n/// CI Engine\npub struct CiEngine {\n state: Arc>,\n graph: JobGraph,\n}\n\nimpl CiEngine {\n /// Create a new CI engine from a trigger event and pipeline definition\n pub async fn new(event: PipelineTriggerEvent, pipeline: PipelineDefinition) -> Result {\n let run_id = PipelineRunId::new();\n\n // Build DAG from pipeline\n let graph = DagBuilder::build(&pipeline, run_id)?;\n\n // Create job state machines\n let mut jobs = HashMap::new();\n for node in &graph.nodes {\n jobs.insert(node.id, JobStateMachine::new(node.id));\n }\n\n let mut state = CiEngineState::new(run_id, event.pipeline_id, event.repo_id);\n state.jobs = jobs;\n\n Ok(Self {\n state: Arc::new(RwLock::new(state)),\n graph,\n })\n }\n\n /// Get current engine state\n pub async fn state(&self) -> CiEngineState {\n self.state.read().await.clone()\n }\n\n /// Start the pipeline run\n pub async fn start(&self) -> Result<()> {\n let mut state = self.state.write().await;\n state.status = PipelineStatus::Running;\n state.started_at = Some(chrono::Utc::now());\n\n // Queue all entry point jobs\n for node in self.graph.entry_points() {\n if let Some(job_state) = state.jobs.get_mut(&node.id) {\n job_state.queue()?;\n }\n }\n\n tracing::info!(\n \"pipeline {} started with {} jobs\",\n state.run_id,\n state.jobs.len()\n );\n\n Ok(())\n }\n\n /// Get jobs ready to be scheduled (dependencies satisfied, not yet queued)\n pub async fn ready_jobs(&self) -> Vec {\n let state = self.state.read().await;\n let mut ready = Vec::new();\n\n for node in &self.graph.nodes {\n let job_state = match state.jobs.get(&node.id) {\n Some(s) => s,\n None => continue,\n };\n\n // Skip if not in queued state\n if job_state.status() != JobStatus::Queued {\n continue;\n }\n\n // Check if all dependencies are satisfied\n let deps_satisfied = node\n .dependencies\n .iter()\n .all(|dep_id| {\n state.jobs.get(dep_id).is_some_and(|s| {\n s.status() == JobStatus::Succeeded\n })\n });\n\n if deps_satisfied {\n ready.push(node.id);\n }\n }\n\n ready\n }\n\n /// Assign a job to a runner\n pub async fn assign_job(&self, job_id: JobId, runner_id: gitforce_common::RunnerId) -> Result<()> {\n let mut state = self.state.write().await;\n if let Some(job_state) = state.jobs.get_mut(&job_id) {\n job_state.assign(runner_id)?;\n }\n Ok(())\n }\n\n /// Mark a job as started\n pub async fn start_job(&self, job_id: JobId) -> Result<()> {\n let mut state = self.state.write().await;\n if let Some(job_state) = state.jobs.get_mut(&job_id) {\n job_state.start()?;\n }\n Ok(())\n }\n\n /// Mark a job as succeeded\n pub async fn succeed_job(&self, job_id: JobId, exit_code: i32) -> Result<()> {\n let mut state = self.state.write().await;\n if let Some(job_state) = state.jobs.get_mut(&job_id) {\n job_state.succeed(exit_code)?;\n\n // Check if pipeline is complete\n if state.all_jobs_finished() {\n state.status = if state.all_jobs_succeeded() {\n PipelineStatus::Succeeded\n } else {\n PipelineStatus::Failed\n };\n state.finished_at = Some(chrono::Utc::now());\n }\n }\n Ok(())\n }\n\n /// Mark a job as failed\n pub async fn fail_job(&self, job_id: JobId, exit_code: i32, error: String) -> Result<()> {\n let mut state = self.state.write().await;\n if let Some(job_state) = state.jobs.get_mut(&job_id) {\n job_state.fail(exit_code, error)?;\n\n // Pipeline fails if any job fails\n if !state.failed_jobs().is_empty() {\n state.status = PipelineStatus::Failed;\n state.finished_at = Some(chrono::Utc::now());\n }\n }\n Ok(())\n }\n\n /// Cancel the pipeline\n pub async fn cancel(&self) -> Result<()> {\n let mut state = self.state.write().await;\n state.status = PipelineStatus::Cancelled;\n state.finished_at = Some(chrono::Utc::now());\n\n for job_state in state.jobs.values_mut() {\n if !job_state.is_terminal() {\n job_state.cancel().ok();\n }\n }\n\n Ok(())\n }\n\n /// Get job info\n pub async fn get_job(&self, job_id: JobId) -> Option {\n let state = self.state.read().await;\n state.jobs.get(&job_id).cloned()\n }\n\n /// Get the job graph\n pub fn graph(&self) -> &JobGraph {\n &self.graph\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::pipeline::{JobDefinition, StepDefinition, TriggerType};\n use gitforce_common::PipelineId;\n use std::collections::HashMap;\n\n fn make_pipeline() -> PipelineDefinition {\n PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n }\n }\n\n #[tokio::test]\n async fn test_engine_lifecycle() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n\n // Initially pending\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Pending);\n\n // Start\n engine.start().await.unwrap();\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Running);\n\n // Build job should be queued (entry point with no dependencies)\n let ready = engine.ready_jobs().await;\n assert_eq!(ready.len(), 1);\n\n // Simulate build completing - job must be assigned first\n let build_job = ready[0];\n let runner_id = gitforce_common::RunnerId::new();\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.succeed_job(build_job, 0).await.unwrap();\n\n // After build succeeds, test job is now ready (still needs to be picked up by scheduler)\n // Note: In real system, scheduler would dequeue it. For test, we verify pipeline completion.\n let state = engine.state().await;\n assert!(!state.failed_jobs().is_empty() || state.status == PipelineStatus::Succeeded || state.jobs.values().any(|j| j.status() == JobStatus::Succeeded));\n }\n\n #[tokio::test]\n async fn test_engine_fail_job() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n let runner_id = gitforce_common::RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.fail_job(build_job, 1, \"build failed\".to_string()).await.unwrap();\n\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Failed);\n assert!(state.finished_at.is_some());\n }\n\n #[tokio::test]\n async fn test_engine_cancel() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n engine.start().await.unwrap();\n\n engine.cancel().await.unwrap();\n\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Cancelled);\n assert!(state.finished_at.is_some());\n }\n\n #[tokio::test]\n async fn test_engine_get_job() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n\n let job = engine.get_job(build_job).await;\n assert!(job.is_some());\n\n let non_existent = gitforce_common::JobId::new();\n let not_found = engine.get_job(non_existent).await;\n assert!(not_found.is_none());\n }\n\n #[tokio::test]\n async fn test_engine_state_all_jobs_finished() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n let state = engine.state().await;\n\n // No jobs finished yet\n assert!(!state.all_jobs_finished());\n }\n\n #[tokio::test]\n async fn test_engine_state_pending_jobs() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n engine.start().await.unwrap();\n\n let state = engine.state().await;\n let pending = state.pending_jobs();\n assert!(!pending.is_empty());\n }\n\n #[tokio::test]\n async fn test_engine_ready_jobs_before_start() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n\n // Before start, no jobs should be ready\n let ready = engine.ready_jobs().await;\n assert!(ready.is_empty());\n }\n\n #[tokio::test]\n async fn test_engine_state_failed_jobs() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, make_pipeline()).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n let runner_id = gitforce_common::RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.fail_job(build_job, 1, \"test failure\".to_string()).await.unwrap();\n\n let state = engine.state().await;\n assert!(!state.failed_jobs().is_empty());\n }\n}\n","traces":[{"line":24,"address":[21303120],"length":1,"stats":{"Line":7}},{"line":34,"address":[2953821],"length":1,"stats":{"Line":2}},{"line":41,"address":[2953696],"length":1,"stats":{"Line":1}},{"line":42,"address":[2913536,2913561],"length":1,"stats":{"Line":3}},{"line":46,"address":[2665712],"length":1,"stats":{"Line":0}},{"line":47,"address":[2913584,2913611],"length":1,"stats":{"Line":0}},{"line":51,"address":[2953472],"length":1,"stats":{"Line":3}},{"line":54,"address":[2953508],"length":1,"stats":{"Line":9}},{"line":55,"address":[2913376,2913394],"length":1,"stats":{"Line":6}},{"line":60,"address":[2953584],"length":1,"stats":{"Line":1}},{"line":63,"address":[3080432,3080446],"length":1,"stats":{"Line":3}},{"line":64,"address":[2913507],"length":1,"stats":{"Line":1}},{"line":65,"address":[3080458],"length":1,"stats":{"Line":1}},{"line":69,"address":[2665618],"length":1,"stats":{"Line":3}},{"line":82,"address":[2954133,2954112],"length":1,"stats":{"Line":20}},{"line":83,"address":[3084811],"length":1,"stats":{"Line":5}},{"line":86,"address":[21434224],"length":1,"stats":{"Line":5}},{"line":89,"address":[2917988],"length":1,"stats":{"Line":4}},{"line":90,"address":[21434643,21434728],"length":1,"stats":{"Line":10}},{"line":91,"address":[21434828,21435825],"length":1,"stats":{"Line":11}},{"line":94,"address":[2918395,2918276],"length":1,"stats":{"Line":9}},{"line":95,"address":[3085766,3085691],"length":1,"stats":{"Line":2}},{"line":97,"address":[3086169],"length":1,"stats":{"Line":2}},{"line":98,"address":[2918591,2918806],"length":1,"stats":{"Line":11}},{"line":99,"address":[2918821],"length":1,"stats":{"Line":9}},{"line":104,"address":[3090339,3090983,3090590,3090304,3090415,3090454],"length":1,"stats":{"Line":13}},{"line":105,"address":[21439770,21439826,21439949,21439723,21440243],"length":1,"stats":{"Line":12}},{"line":109,"address":[2954216,2954208],"length":1,"stats":{"Line":20}},{"line":110,"address":[21254518],"length":1,"stats":{"Line":10}},{"line":111,"address":[21436679,21436600],"length":1,"stats":{"Line":11}},{"line":112,"address":[3087355],"length":1,"stats":{"Line":6}},{"line":115,"address":[2920082,2920259],"length":1,"stats":{"Line":12}},{"line":116,"address":[2922126,2922410,2920330],"length":1,"stats":{"Line":18}},{"line":117,"address":[21438992],"length":1,"stats":{"Line":6}},{"line":121,"address":[3088672,3089113],"length":1,"stats":{"Line":0}},{"line":127,"address":[21437513],"length":1,"stats":{"Line":6}},{"line":131,"address":[2914835,2914656,2914974,2914703,2914787,2915958],"length":1,"stats":{"Line":8}},{"line":132,"address":[2914817,2914764,2914890,2915008],"length":1,"stats":{"Line":4}},{"line":133,"address":[3082366],"length":1,"stats":{"Line":2}},{"line":135,"address":[2915310,2915378],"length":1,"stats":{"Line":4}},{"line":136,"address":[3082774,3082618],"length":1,"stats":{"Line":2}},{"line":137,"address":[3082845],"length":1,"stats":{"Line":1}},{"line":142,"address":[3082861],"length":1,"stats":{"Line":1}},{"line":147,"address":[21432384,21432264,21432333],"length":1,"stats":{"Line":12}},{"line":150,"address":[3083021,3083136],"length":1,"stats":{"Line":4}},{"line":151,"address":[3083154,3083200],"length":1,"stats":{"Line":0}},{"line":152,"address":[2916041],"length":1,"stats":{"Line":0}},{"line":156,"address":[21432396],"length":1,"stats":{"Line":4}},{"line":157,"address":[21432410],"length":1,"stats":{"Line":4}},{"line":161,"address":[3082642],"length":1,"stats":{"Line":2}},{"line":165,"address":[3080672,3080841,3080707,3081732,3080980,3080802],"length":1,"stats":{"Line":4}},{"line":166,"address":[2906953],"length":1,"stats":{"Line":2}},{"line":167,"address":[3081663,3081304,3081232],"length":1,"stats":{"Line":5}},{"line":168,"address":[21430793,21430717],"length":1,"stats":{"Line":6}},{"line":170,"address":[3081438],"length":1,"stats":{"Line":1}},{"line":174,"address":[21303712,21303720],"length":1,"stats":{"Line":12}},{"line":175,"address":[2906809],"length":1,"stats":{"Line":6}},{"line":176,"address":[3094984,3095056,3095390],"length":1,"stats":{"Line":8}},{"line":177,"address":[3095192,3095136],"length":1,"stats":{"Line":6}},{"line":179,"address":[3095165],"length":1,"stats":{"Line":3}},{"line":183,"address":[3083248,3083382,3083421,3083283,3083566,3084593],"length":1,"stats":{"Line":4}},{"line":184,"address":[2907177],"length":1,"stats":{"Line":2}},{"line":185,"address":[3083824,3083902],"length":1,"stats":{"Line":2}},{"line":186,"address":[3083987,3084052],"length":1,"stats":{"Line":2}},{"line":189,"address":[2917037,2917273],"length":1,"stats":{"Line":1}},{"line":190,"address":[2917086,2917132,2917150],"length":1,"stats":{"Line":0}},{"line":191,"address":[21433702],"length":1,"stats":{"Line":0}},{"line":193,"address":[3084364],"length":1,"stats":{"Line":0}},{"line":195,"address":[2917177],"length":1,"stats":{"Line":0}},{"line":198,"address":[21433350],"length":1,"stats":{"Line":1}},{"line":202,"address":[2925232,2925562,2925267,2926499,2926637,2925392],"length":1,"stats":{"Line":4}},{"line":203,"address":[21442336,21442383,21442440,21442569],"length":1,"stats":{"Line":2}},{"line":204,"address":[2925810,2925869],"length":1,"stats":{"Line":2}},{"line":205,"address":[3093622,3093723],"length":1,"stats":{"Line":4}},{"line":208,"address":[3093924,3094214],"length":1,"stats":{"Line":4}},{"line":209,"address":[2926380],"length":1,"stats":{"Line":2}},{"line":210,"address":[2926401],"length":1,"stats":{"Line":2}},{"line":213,"address":[2926020],"length":1,"stats":{"Line":1}},{"line":217,"address":[2954248,2954240],"length":1,"stats":{"Line":4}},{"line":218,"address":[2827044],"length":1,"stats":{"Line":2}},{"line":219,"address":[3091536,3091603],"length":1,"stats":{"Line":2}},{"line":220,"address":[3091607],"length":1,"stats":{"Line":1}},{"line":222,"address":[2924165],"length":1,"stats":{"Line":1}},{"line":223,"address":[3091989,3091891],"length":1,"stats":{"Line":2}},{"line":224,"address":[3092004],"length":1,"stats":{"Line":1}},{"line":228,"address":[3091916],"length":1,"stats":{"Line":1}},{"line":232,"address":[2954264,2954256],"length":1,"stats":{"Line":4}},{"line":233,"address":[3092166,3092213,3092395,3092272],"length":1,"stats":{"Line":2}},{"line":234,"address":[3092628,3092700,3092764],"length":1,"stats":{"Line":3}},{"line":238,"address":[2954192],"length":1,"stats":{"Line":1}}],"covered":81,"coverable":90},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","executor.rs"],"content":"//! Pipeline executor - coordinates with scheduler and runners\n\nuse crate::dag::JobGraph;\nuse crate::engine::{CiEngine, CiEngineState};\nuse gitforce_common::{JobId, Result, RunnerId};\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// Pipeline executor coordinates CI engine with external systems\npub struct PipelineExecutor {\n engine: Arc>,\n}\n\nimpl PipelineExecutor {\n /// Create a new pipeline executor\n pub fn new(engine: CiEngine) -> Self {\n Self {\n engine: Arc::new(RwLock::new(engine)),\n }\n }\n\n /// Start the pipeline\n pub async fn start(&self) -> Result<()> {\n let engine = self.engine.read().await;\n engine.start().await\n }\n\n /// Get the engine state\n pub async fn state(&self) -> CiEngineState {\n let engine = self.engine.read().await;\n engine.state().await\n }\n\n /// Get ready jobs for scheduling\n pub async fn ready_jobs(&self) -> Vec {\n let engine = self.engine.read().await;\n engine.ready_jobs().await\n }\n\n /// Get the job graph\n pub async fn graph(&self) -> JobGraph {\n let engine = self.engine.read().await;\n engine.graph().clone()\n }\n\n /// Assign a job to a runner\n pub async fn assign_job(&self, job_id: JobId, runner_id: RunnerId) -> Result<()> {\n let engine = self.engine.read().await;\n engine.assign_job(job_id, runner_id).await\n }\n\n /// Start a job\n pub async fn start_job(&self, job_id: JobId) -> Result<()> {\n let engine = self.engine.read().await;\n engine.start_job(job_id).await\n }\n\n /// Complete a job successfully\n pub async fn succeed_job(&self, job_id: JobId, exit_code: i32) -> Result<()> {\n let engine = self.engine.read().await;\n engine.succeed_job(job_id, exit_code).await\n }\n\n /// Fail a job\n pub async fn fail_job(&self, job_id: JobId, exit_code: i32, error: String) -> Result<()> {\n let engine = self.engine.read().await;\n engine.fail_job(job_id, exit_code, error).await\n }\n\n /// Cancel the pipeline\n pub async fn cancel(&self) -> Result<()> {\n let engine = self.engine.read().await;\n engine.cancel().await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::pipeline::{PipelineDefinition, PipelineTriggerEvent, TriggerType};\n use gitforce_common::{PipelineId, RepoId};\n use std::collections::HashMap;\n\n async fn create_test_engine() -> CiEngine {\n let pipeline_id = PipelineId::new();\n let repo_id = RepoId::new();\n let trigger = PipelineTriggerEvent::new(\n pipeline_id,\n repo_id,\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![],\n };\n CiEngine::new(trigger, pipeline).await.unwrap()\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_new() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let state = executor.state().await;\n assert_eq!(state.status, gitforce_common::PipelineStatus::Pending);\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_ready_jobs() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let jobs = executor.ready_jobs().await;\n assert!(jobs.is_empty());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_graph() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let graph = executor.graph().await;\n assert_eq!(graph.nodes.len(), 0);\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_start() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let result = executor.start().await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_cancel() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let result = executor.cancel().await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_assign_job() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let job_id = JobId::new();\n let runner_id = RunnerId::new();\n // Assigning non-existent job should be no-op (returns Ok)\n let result = executor.assign_job(job_id, runner_id).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_start_job() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let job_id = JobId::new();\n let result = executor.start_job(job_id).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_succeed_job() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let job_id = JobId::new();\n let result = executor.succeed_job(job_id, 0).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pipeline_executor_fail_job() {\n let engine = create_test_engine().await;\n let executor = PipelineExecutor::new(engine);\n let job_id = JobId::new();\n let result = executor.fail_job(job_id, 1, \"test error\".to_string()).await;\n assert!(result.is_ok());\n }\n}\n","traces":[{"line":16,"address":[2685840],"length":1,"stats":{"Line":2}},{"line":18,"address":[2685850],"length":1,"stats":{"Line":2}},{"line":23,"address":[2685944,2685936],"length":1,"stats":{"Line":4}},{"line":24,"address":[2828639],"length":1,"stats":{"Line":2}},{"line":25,"address":[2828654],"length":1,"stats":{"Line":2}},{"line":29,"address":[2882749,2882627,2882729,2883264,2882592,2882695,2882873],"length":1,"stats":{"Line":4}},{"line":30,"address":[2828847],"length":1,"stats":{"Line":2}},{"line":31,"address":[2883116,2883186,2882734,2883275],"length":1,"stats":{"Line":2}},{"line":35,"address":[2685776,2685784],"length":1,"stats":{"Line":4}},{"line":36,"address":[2818239],"length":1,"stats":{"Line":2}},{"line":37,"address":[2818254],"length":1,"stats":{"Line":2}},{"line":41,"address":[2685928,2685920],"length":1,"stats":{"Line":4}},{"line":42,"address":[2828532],"length":1,"stats":{"Line":2}},{"line":43,"address":[2881432,2881486],"length":1,"stats":{"Line":2}},{"line":47,"address":[2878534,2877792,2877965,2877827,2877945,2877911,2878089],"length":1,"stats":{"Line":4}},{"line":48,"address":[2818031],"length":1,"stats":{"Line":2}},{"line":49,"address":[2818046],"length":1,"stats":{"Line":2}},{"line":53,"address":[2686048,2686056],"length":1,"stats":{"Line":4}},{"line":54,"address":[2817807],"length":1,"stats":{"Line":2}},{"line":55,"address":[2817822],"length":1,"stats":{"Line":2}},{"line":59,"address":[2685804,2685792],"length":1,"stats":{"Line":4}},{"line":60,"address":[2818447],"length":1,"stats":{"Line":2}},{"line":61,"address":[2818462],"length":1,"stats":{"Line":2}},{"line":65,"address":[2685996,2685984],"length":1,"stats":{"Line":4}},{"line":66,"address":[2830985],"length":1,"stats":{"Line":2}},{"line":67,"address":[2831000],"length":1,"stats":{"Line":2}},{"line":71,"address":[2685968,2685976],"length":1,"stats":{"Line":4}},{"line":72,"address":[2829503],"length":1,"stats":{"Line":2}},{"line":73,"address":[2829518],"length":1,"stats":{"Line":2}}],"covered":29,"coverable":29},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","lib.rs"],"content":"//! GitForce CI Orchestrator\n//!\n//! Pipeline parsing, DAG building, and job execution orchestration.\n\npub mod dag;\npub mod engine;\npub mod executor;\npub mod pipeline;\npub mod state;\n\npub use dag::{DagBuilder, JobGraph, JobNode};\npub use engine::CiEngine;\npub use executor::PipelineExecutor;\npub use pipeline::{PipelineDefinition, PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition};\npub use state::JobStateMachine;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","pipeline.rs"],"content":"//! Pipeline definition and parsing\n\nuse gitforce_common::{PipelineId, RepoId};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Pipeline trigger event\n#[derive(Debug, Clone)]\npub struct PipelineTriggerEvent {\n pub pipeline_id: PipelineId,\n pub repo_id: RepoId,\n pub commit_hash: String,\n pub trigger_type: TriggerType,\n pub ref_name: Option,\n pub actor_id: Option,\n}\n\nimpl PipelineTriggerEvent {\n pub fn new(\n pipeline_id: PipelineId,\n repo_id: RepoId,\n commit_hash: String,\n trigger_type: TriggerType,\n ) -> Self {\n Self {\n pipeline_id,\n repo_id,\n commit_hash,\n trigger_type,\n ref_name: None,\n actor_id: None,\n }\n }\n\n pub fn with_ref(mut self, ref_name: String) -> Self {\n self.ref_name = Some(ref_name);\n self\n }\n\n pub fn with_actor(mut self, actor_id: gitforce_common::UserId) -> Self {\n self.actor_id = Some(actor_id);\n self\n }\n}\n\n/// Trigger type\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum TriggerType {\n Push,\n Tag,\n PullRequest,\n Manual,\n}\n\nimpl TriggerType {\n pub fn as_str(&self) -> &'static str {\n match self {\n TriggerType::Push => \"push\",\n TriggerType::Tag => \"tag\",\n TriggerType::PullRequest => \"pull_request\",\n TriggerType::Manual => \"manual\",\n }\n }\n}\n\n/// Pipeline definition (loaded from .gitforce.yml)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineDefinition {\n pub name: String,\n pub version: String,\n pub trigger_on: Vec,\n pub environment: HashMap,\n pub jobs: Vec,\n}\n\nimpl PipelineDefinition {\n /// Parse pipeline definition from YAML\n pub fn parse(yaml: &str) -> Result {\n serde_yaml::from_str(yaml)\n }\n\n /// Convert to YAML\n pub fn to_yaml(&self) -> Result {\n serde_yaml::to_string(self)\n }\n}\n\n/// Job definition within a pipeline\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobDefinition {\n pub name: String,\n pub image: String,\n #[serde(default)]\n pub needs: Vec,\n #[serde(default)]\n pub env: HashMap,\n pub steps: Vec,\n pub timeout: Option,\n pub retry: Option,\n}\n\nimpl JobDefinition {\n /// Check if this job has any dependencies\n pub fn has_dependencies(&self) -> bool {\n !self.needs.is_empty()\n }\n}\n\n/// Step definition within a job\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct StepDefinition {\n pub name: String,\n pub run: String,\n pub env: Option>,\n pub working_directory: Option,\n pub condition: Option,\n}\n\nimpl StepDefinition {\n /// Get environment variables for this step\n pub fn get_env(&self) -> &HashMap {\n self.env.as_ref().unwrap_or(&EMPTY_ENV)\n }\n}\n\nlazy_static::lazy_static! {\n static ref EMPTY_ENV: HashMap = HashMap::new();\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_parse_pipeline() {\n let yaml = r#\"\nname: test-pipeline\nversion: \"1.0\"\ntrigger_on:\n - push\nenvironment:\n RUST_BACKTRACE: \"1\"\njobs:\n - name: build\n image: rust:latest\n steps:\n - name: build\n run: cargo build\n\"#;\n\n let pipeline = PipelineDefinition::parse(yaml).unwrap();\n assert_eq!(pipeline.name, \"test-pipeline\");\n assert_eq!(pipeline.jobs.len(), 1);\n assert_eq!(pipeline.jobs[0].name, \"build\");\n }\n\n #[test]\n fn test_trigger_type() {\n assert_eq!(TriggerType::Push.as_str(), \"push\");\n assert_eq!(TriggerType::Tag.as_str(), \"tag\");\n assert_eq!(TriggerType::PullRequest.as_str(), \"pull_request\");\n assert_eq!(TriggerType::Manual.as_str(), \"manual\");\n }\n\n #[test]\n fn test_trigger_type_variants() {\n assert!(matches!(TriggerType::Push, TriggerType::Push));\n assert!(matches!(TriggerType::Tag, TriggerType::Tag));\n assert!(matches!(TriggerType::PullRequest, TriggerType::PullRequest));\n assert!(matches!(TriggerType::Manual, TriggerType::Manual));\n }\n\n #[test]\n fn test_pipeline_definition_to_yaml() {\n let def = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![],\n };\n let yaml = def.to_yaml().unwrap();\n assert!(yaml.contains(\"test\"));\n }\n\n #[test]\n fn test_job_definition_has_dependencies() {\n let job = JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"dep\".to_string()],\n env: HashMap::new(),\n steps: vec![],\n timeout: None,\n retry: None,\n };\n assert!(job.has_dependencies());\n\n let job2 = JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![],\n timeout: None,\n retry: None,\n };\n assert!(!job2.has_dependencies());\n }\n\n #[test]\n fn test_step_definition_get_env() {\n let mut env = HashMap::new();\n env.insert(\"RUST_BACKTRACE\".to_string(), \"1\".to_string());\n\n let step = StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: Some(env),\n working_directory: None,\n condition: None,\n };\n assert_eq!(step.get_env().get(\"RUST_BACKTRACE\"), Some(&\"1\".to_string()));\n }\n\n #[test]\n fn test_step_definition_get_env_empty() {\n let step = StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n };\n assert!(step.get_env().is_empty());\n }\n\n #[test]\n fn test_pipeline_trigger_event_new() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n assert_eq!(event.commit_hash, \"abc123\");\n assert!(event.ref_name.is_none());\n assert!(event.actor_id.is_none());\n }\n\n #[test]\n fn test_pipeline_trigger_event_with_ref() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n ).with_ref(\"refs/heads/main\".to_string());\n\n assert_eq!(event.ref_name, Some(\"refs/heads/main\".to_string()));\n }\n\n #[test]\n fn test_pipeline_trigger_event_with_actor() {\n let user_id = gitforce_common::UserId::new();\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n ).with_actor(user_id);\n\n assert_eq!(event.actor_id, Some(user_id));\n }\n}\n","traces":[{"line":19,"address":[2804944],"length":1,"stats":{"Line":5}},{"line":35,"address":[2956486,2956320],"length":1,"stats":{"Line":1}},{"line":36,"address":[2956347,2956439],"length":1,"stats":{"Line":2}},{"line":37,"address":[2805262],"length":1,"stats":{"Line":1}},{"line":40,"address":[21305392],"length":1,"stats":{"Line":1}},{"line":41,"address":[2804876],"length":1,"stats":{"Line":1}},{"line":42,"address":[2804923],"length":1,"stats":{"Line":1}},{"line":57,"address":[2955728],"length":1,"stats":{"Line":1}},{"line":58,"address":[21305061],"length":1,"stats":{"Line":1}},{"line":59,"address":[21305092],"length":1,"stats":{"Line":1}},{"line":60,"address":[2804603],"length":1,"stats":{"Line":1}},{"line":61,"address":[2804626],"length":1,"stats":{"Line":1}},{"line":62,"address":[2955833],"length":1,"stats":{"Line":1}},{"line":79,"address":[2804784],"length":1,"stats":{"Line":1}},{"line":80,"address":[21305317],"length":1,"stats":{"Line":1}},{"line":84,"address":[2956016],"length":1,"stats":{"Line":1}},{"line":85,"address":[2956033],"length":1,"stats":{"Line":1}},{"line":105,"address":[2955872],"length":1,"stats":{"Line":1}},{"line":106,"address":[2955877],"length":1,"stats":{"Line":1}},{"line":122,"address":[2955904],"length":1,"stats":{"Line":1}},{"line":123,"address":[2955913],"length":1,"stats":{"Line":1}},{"line":128,"address":[2958712],"length":1,"stats":{"Line":1}}],"covered":22,"coverable":22},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","src","state.rs"],"content":"//! Job state machine\n\nuse gitforce_common::{JobId, JobStatus, Result, RunnerId};\n\n/// Job state machine for managing job lifecycle\n#[derive(Debug, Clone)]\npub struct JobStateMachine {\n job_id: JobId,\n status: JobStatus,\n runner_id: Option,\n exit_code: Option,\n error_message: Option,\n}\n\nimpl JobStateMachine {\n /// Create a new state machine for a job\n pub fn new(job_id: JobId) -> Self {\n Self {\n job_id,\n status: JobStatus::Pending,\n runner_id: None,\n exit_code: None,\n error_message: None,\n }\n }\n\n /// Get current status\n pub fn status(&self) -> JobStatus {\n self.status\n }\n\n /// Get runner ID if assigned\n pub fn runner_id(&self) -> Option {\n self.runner_id\n }\n\n /// Get exit code if finished\n pub fn exit_code(&self) -> Option {\n self.exit_code\n }\n\n /// Get error message if failed\n pub fn error_message(&self) -> Option<&str> {\n self.error_message.as_deref()\n }\n\n /// Transition to queued state\n pub fn queue(&mut self) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Queued)?;\n self.status = JobStatus::Queued;\n tracing::debug!(\"job {} queued\", self.job_id);\n Ok(())\n }\n\n /// Transition to assigned state\n pub fn assign(&mut self, runner_id: RunnerId) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Assigned)?;\n self.status = JobStatus::Assigned;\n self.runner_id = Some(runner_id);\n tracing::debug!(\"job {} assigned to runner {}\", self.job_id, runner_id);\n Ok(())\n }\n\n /// Transition to running state\n pub fn start(&mut self) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Running)?;\n self.status = JobStatus::Running;\n tracing::debug!(\"job {} started\", self.job_id);\n Ok(())\n }\n\n /// Transition to succeeded state\n pub fn succeed(&mut self, exit_code: i32) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Succeeded)?;\n self.status = JobStatus::Succeeded;\n self.exit_code = Some(exit_code);\n tracing::info!(\"job {} succeeded with exit code {}\", self.job_id, exit_code);\n Ok(())\n }\n\n /// Transition to failed state\n pub fn fail(&mut self, exit_code: i32, error: String) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Failed)?;\n self.status = JobStatus::Failed;\n self.exit_code = Some(exit_code);\n self.error_message = Some(error);\n tracing::error!(\"job {} failed with exit code {}: {}\", self.job_id, exit_code, self.error_message.as_ref().unwrap());\n Ok(())\n }\n\n /// Transition to cancelled state\n pub fn cancel(&mut self) -> Result<()> {\n self.ensure_valid_transition(JobStatus::Cancelled)?;\n self.status = JobStatus::Cancelled;\n tracing::warn!(\"job {} cancelled\", self.job_id);\n Ok(())\n }\n\n /// Transition to timed out state\n pub fn timeout(&mut self) -> Result<()> {\n self.ensure_valid_transition(JobStatus::TimedOut)?;\n self.status = JobStatus::TimedOut;\n tracing::error!(\"job {} timed out\", self.job_id);\n Ok(())\n }\n\n /// Check if the job is in a terminal state\n pub fn is_terminal(&self) -> bool {\n self.status.is_terminal()\n }\n\n /// Get a summary of the job state\n pub fn summary(&self) -> JobStateSummary {\n JobStateSummary {\n job_id: self.job_id,\n status: self.status,\n runner_id: self.runner_id,\n exit_code: self.exit_code,\n error_message: self.error_message.clone(),\n }\n }\n\n fn ensure_valid_transition(&self, target: JobStatus) -> Result<()> {\n let valid = match (&self.status, target) {\n // From pending\n (JobStatus::Pending, JobStatus::Queued) => true,\n (JobStatus::Pending, JobStatus::Cancelled) => true,\n\n // From queued\n (JobStatus::Queued, JobStatus::Assigned) => true,\n (JobStatus::Queued, JobStatus::Cancelled) => true,\n (JobStatus::Queued, JobStatus::Failed) => true,\n\n // From assigned\n (JobStatus::Assigned, JobStatus::Running) => true,\n (JobStatus::Assigned, JobStatus::Cancelled) => true,\n\n // From running\n (JobStatus::Running, JobStatus::Succeeded) => true,\n (JobStatus::Running, JobStatus::Failed) => true,\n (JobStatus::Running, JobStatus::TimedOut) => true,\n (JobStatus::Running, JobStatus::Cancelled) => true,\n\n // Terminal states\n _ => false,\n };\n\n if !valid {\n return Err(gitforce_common::Error::invalid_input(format!(\n \"invalid state transition from {:?} to {:?}\",\n self.status, target\n )));\n }\n\n Ok(())\n }\n}\n\n/// Summary of job state\n#[derive(Debug, Clone)]\npub struct JobStateSummary {\n pub job_id: JobId,\n pub status: JobStatus,\n pub runner_id: Option,\n pub exit_code: Option,\n pub error_message: Option,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_job_state_transitions() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n assert_eq!(state.status(), JobStatus::Pending);\n\n state.queue().unwrap();\n assert_eq!(state.status(), JobStatus::Queued);\n\n state.assign(RunnerId::new()).unwrap();\n assert_eq!(state.status(), JobStatus::Assigned);\n\n state.start().unwrap();\n assert_eq!(state.status(), JobStatus::Running);\n\n state.succeed(0).unwrap();\n assert_eq!(state.status(), JobStatus::Succeeded);\n assert_eq!(state.exit_code(), Some(0));\n }\n\n #[test]\n fn test_invalid_transition() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n // Can't go from pending to running directly\n assert!(state.start().is_err());\n }\n\n #[test]\n fn test_fail_transition() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.start().unwrap();\n state.fail(1, \"build error\".to_string()).unwrap();\n\n assert_eq!(state.status(), JobStatus::Failed);\n assert_eq!(state.exit_code(), Some(1));\n assert_eq!(state.error_message(), Some(\"build error\"));\n }\n\n #[test]\n fn test_cancel_from_pending() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.cancel().unwrap();\n assert_eq!(state.status(), JobStatus::Cancelled);\n }\n\n #[test]\n fn test_cancel_from_queued() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.cancel().unwrap();\n assert_eq!(state.status(), JobStatus::Cancelled);\n }\n\n #[test]\n fn test_cancel_from_assigned() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.cancel().unwrap();\n assert_eq!(state.status(), JobStatus::Cancelled);\n }\n\n #[test]\n fn test_cancel_from_running() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.start().unwrap();\n state.cancel().unwrap();\n assert_eq!(state.status(), JobStatus::Cancelled);\n }\n\n #[test]\n fn test_timeout_transition() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.start().unwrap();\n state.timeout().unwrap();\n\n assert_eq!(state.status(), JobStatus::TimedOut);\n }\n\n #[test]\n fn test_runner_id_accessor() {\n let job_id = JobId::new();\n let runner_id = RunnerId::new();\n let mut state = JobStateMachine::new(job_id);\n\n assert!(state.runner_id().is_none());\n\n state.queue().unwrap();\n state.assign(runner_id).unwrap();\n\n assert_eq!(state.runner_id(), Some(runner_id));\n }\n\n #[test]\n fn test_is_terminal() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n assert!(!state.is_terminal());\n\n state.queue().unwrap();\n assert!(!state.is_terminal());\n\n state.assign(RunnerId::new()).unwrap();\n assert!(!state.is_terminal());\n\n state.start().unwrap();\n assert!(!state.is_terminal());\n\n state.succeed(0).unwrap();\n assert!(state.is_terminal());\n }\n\n #[test]\n fn test_summary() {\n let job_id = JobId::new();\n let runner_id = RunnerId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(runner_id).unwrap();\n state.start().unwrap();\n state.succeed(0).unwrap();\n\n let summary = state.summary();\n assert_eq!(summary.job_id, job_id);\n assert_eq!(summary.status, JobStatus::Succeeded);\n assert_eq!(summary.runner_id, Some(runner_id));\n assert_eq!(summary.exit_code, Some(0));\n assert!(summary.error_message.is_none());\n }\n\n #[test]\n fn test_summary_with_error() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.start().unwrap();\n state.fail(1, \"test error\".to_string()).unwrap();\n\n let summary = state.summary();\n assert_eq!(summary.error_message, Some(\"test error\".to_string()));\n }\n\n #[test]\n fn test_pending_to_cancelled() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n // Direct cancel from pending\n state.cancel().unwrap();\n assert_eq!(state.status(), JobStatus::Cancelled);\n }\n\n #[test]\n fn test_cannot_assign_from_pending() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n // Can't assign directly from pending - must queue first\n assert!(state.assign(RunnerId::new()).is_err());\n }\n\n #[test]\n fn test_cannot_fail_from_pending() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n // Can't fail directly from pending - must go through queue/assigned/running\n assert!(state.fail(1, \"error\".to_string()).is_err());\n }\n\n #[test]\n fn test_cannot_succeed_from_pending() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n // Can't succeed directly from pending\n assert!(state.succeed(0).is_err());\n }\n}\n","traces":[{"line":17,"address":[21272000],"length":1,"stats":{"Line":2}},{"line":28,"address":[21279584],"length":1,"stats":{"Line":1}},{"line":29,"address":[2648069],"length":1,"stats":{"Line":1}},{"line":33,"address":[21282400],"length":1,"stats":{"Line":1}},{"line":34,"address":[21282408],"length":1,"stats":{"Line":1}},{"line":38,"address":[2650864],"length":1,"stats":{"Line":1}},{"line":39,"address":[21282389],"length":1,"stats":{"Line":1}},{"line":43,"address":[21271376],"length":1,"stats":{"Line":1}},{"line":44,"address":[21271381],"length":1,"stats":{"Line":1}},{"line":48,"address":[21274512],"length":1,"stats":{"Line":6}},{"line":49,"address":[2914254],"length":1,"stats":{"Line":6}},{"line":50,"address":[21274692],"length":1,"stats":{"Line":7}},{"line":51,"address":[21274696,21275205],"length":1,"stats":{"Line":5}},{"line":52,"address":[2643664],"length":1,"stats":{"Line":3}},{"line":56,"address":[2645424],"length":1,"stats":{"Line":2}},{"line":57,"address":[2916691],"length":1,"stats":{"Line":3}},{"line":58,"address":[2916852],"length":1,"stats":{"Line":2}},{"line":59,"address":[2645622],"length":1,"stats":{"Line":2}},{"line":60,"address":[21277785,21277209],"length":1,"stats":{"Line":6}},{"line":61,"address":[21277767],"length":1,"stats":{"Line":3}},{"line":65,"address":[2644208],"length":1,"stats":{"Line":1}},{"line":66,"address":[21275758],"length":1,"stats":{"Line":1}},{"line":67,"address":[2915620],"length":1,"stats":{"Line":3}},{"line":68,"address":[21276421,21275912],"length":1,"stats":{"Line":7}},{"line":69,"address":[2644880],"length":1,"stats":{"Line":5}},{"line":73,"address":[2919312],"length":1,"stats":{"Line":1}},{"line":74,"address":[21279634],"length":1,"stats":{"Line":1}},{"line":75,"address":[2648268],"length":1,"stats":{"Line":3}},{"line":76,"address":[2919506],"length":1,"stats":{"Line":3}},{"line":77,"address":[2648286,2648857],"length":1,"stats":{"Line":6}},{"line":78,"address":[2648839],"length":1,"stats":{"Line":3}},{"line":82,"address":[21274473,21274505,21272112],"length":1,"stats":{"Line":1}},{"line":83,"address":[2911878,2911955],"length":1,"stats":{"Line":2}},{"line":84,"address":[21272460],"length":1,"stats":{"Line":2}},{"line":85,"address":[2912176],"length":1,"stats":{"Line":2}},{"line":86,"address":[2640985],"length":1,"stats":{"Line":2}},{"line":87,"address":[21272656,21273067],"length":1,"stats":{"Line":4}},{"line":88,"address":[2641553],"length":1,"stats":{"Line":2}},{"line":92,"address":[2918080],"length":1,"stats":{"Line":1}},{"line":93,"address":[21278398],"length":1,"stats":{"Line":1}},{"line":94,"address":[2647026],"length":1,"stats":{"Line":1}},{"line":95,"address":[2918264,2918773],"length":1,"stats":{"Line":2}},{"line":96,"address":[2647520],"length":1,"stats":{"Line":2}},{"line":100,"address":[21281168],"length":1,"stats":{"Line":1}},{"line":101,"address":[2920910],"length":1,"stats":{"Line":1}},{"line":102,"address":[2649826],"length":1,"stats":{"Line":1}},{"line":103,"address":[21281352,21281861],"length":1,"stats":{"Line":2}},{"line":104,"address":[21281843],"length":1,"stats":{"Line":1}},{"line":108,"address":[2639856],"length":1,"stats":{"Line":1}},{"line":109,"address":[2911061],"length":1,"stats":{"Line":1}},{"line":113,"address":[2649440],"length":1,"stats":{"Line":1}},{"line":115,"address":[21280979],"length":1,"stats":{"Line":2}},{"line":116,"address":[2649477],"length":1,"stats":{"Line":2}},{"line":117,"address":[2649484],"length":1,"stats":{"Line":2}},{"line":118,"address":[2920741],"length":1,"stats":{"Line":2}},{"line":119,"address":[2649523],"length":1,"stats":{"Line":2}},{"line":123,"address":[21271392],"length":1,"stats":{"Line":2}},{"line":124,"address":[2639940,2639998],"length":1,"stats":{"Line":9}},{"line":126,"address":[21271645],"length":1,"stats":{"Line":7}},{"line":127,"address":[2640164],"length":1,"stats":{"Line":1}},{"line":130,"address":[21271670],"length":1,"stats":{"Line":2}},{"line":131,"address":[2640196],"length":1,"stats":{"Line":1}},{"line":132,"address":[2911389],"length":1,"stats":{"Line":0}},{"line":135,"address":[21271691],"length":1,"stats":{"Line":3}},{"line":136,"address":[2640210],"length":1,"stats":{"Line":1}},{"line":139,"address":[2640217],"length":1,"stats":{"Line":1}},{"line":140,"address":[2911424],"length":1,"stats":{"Line":1}},{"line":141,"address":[2640238],"length":1,"stats":{"Line":1}},{"line":142,"address":[2911431],"length":1,"stats":{"Line":1}},{"line":145,"address":[2911184],"length":1,"stats":{"Line":1}},{"line":148,"address":[2911369],"length":1,"stats":{"Line":7}},{"line":149,"address":[2911450],"length":1,"stats":{"Line":3}},{"line":155,"address":[2911677],"length":1,"stats":{"Line":1}}],"covered":72,"coverable":73},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-ci","tests","integration.rs"],"content":"//! Integration tests for GitForce CI\n//!\n//! These tests require Docker and a running test database.\n\nuse gitforce_ci::{CiEngine, PipelineDefinition, PipelineTriggerEvent, TriggerType, DagBuilder};\nuse gitforce_common::{PipelineId, RepoId, PipelineRunId};\nuse std::collections::HashMap;\n\n/// Create a test pipeline definition\nfn make_pipeline(jobs: Vec<(&str, Vec<&str>)>) -> PipelineDefinition {\n let jobs = jobs.into_iter().map(|(name, needs)| {\n gitforce_ci::JobDefinition {\n name: name.to_string(),\n image: \"rust:latest\".to_string(),\n needs: needs.into_iter().map(|s| s.to_string()).collect(),\n env: HashMap::new(),\n steps: vec![],\n timeout: None,\n retry: None,\n }\n }).collect();\n\n PipelineDefinition {\n name: \"test-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs,\n }\n}\n\n#[tokio::test]\nasync fn test_ci_engine_with_full_pipeline() {\n let pipeline_id = PipelineId::new();\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n\n let trigger = PipelineTriggerEvent::new(\n pipeline_id,\n repo_id,\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let pipeline = make_pipeline(vec![\n (\"build\", vec![]),\n (\"test\", vec![\"build\"]),\n (\"deploy\", vec![\"test\"]),\n ]);\n\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n assert_eq!(graph.nodes.len(), 3);\n\n let engine = CiEngine::new(trigger, pipeline).await.unwrap();\n assert_eq!(engine.state().await.status, gitforce_common::PipelineStatus::Pending);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_single_job() {\n let pipeline_id = PipelineId::new();\n let repo_id = RepoId::new();\n let trigger = PipelineTriggerEvent::new(\n pipeline_id,\n repo_id,\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let pipeline = make_pipeline(vec![\n (\"build\", vec![]),\n ]);\n\n let engine = CiEngine::new(trigger, pipeline).await.unwrap();\n let state = engine.state().await;\n assert_eq!(state.status, gitforce_common::PipelineStatus::Pending);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_no_jobs() {\n let pipeline_id = PipelineId::new();\n let repo_id = RepoId::new();\n let trigger = PipelineTriggerEvent::new(\n pipeline_id,\n repo_id,\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let pipeline = make_pipeline(vec![]);\n\n let engine = CiEngine::new(trigger, pipeline).await.unwrap();\n let ready = engine.ready_jobs().await;\n assert!(ready.is_empty());\n}\n\n#[test]\nfn test_dag_with_parallel_jobs() {\n let pipeline = make_pipeline(vec![\n (\"build\", vec![]),\n (\"test1\", vec![\"build\"]),\n (\"test2\", vec![\"build\"]),\n (\"integration\", vec![\"test1\", \"test2\"]),\n ]);\n\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n assert_eq!(graph.nodes.len(), 4);\n assert!(!graph.has_cycle());\n\n let order = graph.topological_order();\n assert_eq!(order.len(), 4);\n}\n","traces":[{"line":1,"address":[2619168,2619181],"length":1,"stats":{"Line":2}},{"line":10,"address":[2615344,2616020],"length":1,"stats":{"Line":1}},{"line":11,"address":[2615361],"length":1,"stats":{"Line":3}},{"line":12,"address":[2591002],"length":1,"stats":{"Line":2}},{"line":13,"address":[2590612],"length":1,"stats":{"Line":1}},{"line":14,"address":[2590669],"length":1,"stats":{"Line":1}},{"line":15,"address":[2590735,2591248,2591301,2590843],"length":1,"stats":{"Line":4}},{"line":16,"address":[2590867],"length":1,"stats":{"Line":1}},{"line":17,"address":[2590932],"length":1,"stats":{"Line":2}},{"line":18,"address":[2590984],"length":1,"stats":{"Line":2}},{"line":20,"address":[2590697,2590792,2590886,2590939],"length":1,"stats":{"Line":0}},{"line":21,"address":[2615437],"length":1,"stats":{"Line":3}},{"line":23,"address":[2615837],"length":1,"stats":{"Line":1}},{"line":24,"address":[2615442],"length":1,"stats":{"Line":1}},{"line":25,"address":[2615514],"length":1,"stats":{"Line":1}},{"line":26,"address":[2615642,2615586],"length":1,"stats":{"Line":2}},{"line":27,"address":[2615730],"length":1,"stats":{"Line":1}},{"line":28,"address":[2615798],"length":1,"stats":{"Line":1}},{"line":29,"address":[2615545,2615597,2615749],"length":1,"stats":{"Line":0}},{"line":30,"address":[2615473,2616012],"length":1,"stats":{"Line":2}},{"line":32,"address":[2618711,2618813],"length":1,"stats":{"Line":5}},{"line":33,"address":[2618704,2619140,2619134],"length":1,"stats":{"Line":3}},{"line":34,"address":[2595225],"length":1,"stats":{"Line":1}},{"line":35,"address":[2595354],"length":1,"stats":{"Line":1}},{"line":36,"address":[2595373],"length":1,"stats":{"Line":1}},{"line":38,"address":[2595462],"length":1,"stats":{"Line":1}},{"line":41,"address":[2595392],"length":1,"stats":{"Line":1}},{"line":43,"address":[2595542],"length":1,"stats":{"Line":1}},{"line":45,"address":[2595757,2597114,2595908,2595549,2595649,2596170,2596019,2596277,2595618],"length":1,"stats":{"Line":3}},{"line":46,"address":[2595634,2595678],"length":1,"stats":{"Line":2}},{"line":47,"address":[2595794,2595737,2595940],"length":1,"stats":{"Line":3}},{"line":48,"address":[2596056,2596202,2595999],"length":1,"stats":{"Line":3}},{"line":49,"address":[2596521],"length":1,"stats":{"Line":1}},{"line":51,"address":[2596528,2596621],"length":1,"stats":{"Line":2}},{"line":52,"address":[2596724,2596655],"length":1,"stats":{"Line":2}},{"line":54,"address":[2604242],"length":1,"stats":{"Line":1}},{"line":55,"address":[2595297,2597552,2597716,2597626],"length":1,"stats":{"Line":3}},{"line":56,"address":[2604327,2604425,2604469,2604302,2604387,2604349],"length":1,"stats":{"Line":3}},{"line":58,"address":[2593049,2593925,2592960,2595002,2593086,2592985,2593106],"length":1,"stats":{"Line":5}},{"line":59,"address":[2595056,2595074],"length":1,"stats":{"Line":3}},{"line":60,"address":[2593029],"length":1,"stats":{"Line":1}},{"line":61,"address":[2593144],"length":1,"stats":{"Line":1}},{"line":62,"address":[2593224],"length":1,"stats":{"Line":1}},{"line":65,"address":[2593160],"length":1,"stats":{"Line":1}},{"line":67,"address":[2593301],"length":1,"stats":{"Line":1}},{"line":69,"address":[2593305,2593364,2593395,2593499],"length":1,"stats":{"Line":3}},{"line":70,"address":[2593380,2593424],"length":1,"stats":{"Line":2}},{"line":73,"address":[2604014],"length":1,"stats":{"Line":1}},{"line":74,"address":[2604029],"length":1,"stats":{"Line":2}},{"line":75,"address":[2594813,2594908],"length":1,"stats":{"Line":3}},{"line":76,"address":[2594952,2595093,2593322,2594860,2594290,2593196,2595024],"length":1,"stats":{"Line":3}},{"line":78,"address":[2592003,2592866,2591328,2591454,2592481,2591417,2591353,2591474],"length":1,"stats":{"Line":5}},{"line":79,"address":[2592912,2592930],"length":1,"stats":{"Line":3}},{"line":80,"address":[2591397],"length":1,"stats":{"Line":1}},{"line":81,"address":[2591512],"length":1,"stats":{"Line":1}},{"line":82,"address":[2591592],"length":1,"stats":{"Line":1}},{"line":85,"address":[2591528],"length":1,"stats":{"Line":1}},{"line":87,"address":[2591666],"length":1,"stats":{"Line":1}},{"line":89,"address":[2591678,2591743],"length":1,"stats":{"Line":2}},{"line":91,"address":[2603790],"length":1,"stats":{"Line":1}},{"line":92,"address":[2591459,2592334,2592402,2592492],"length":1,"stats":{"Line":2}},{"line":93,"address":[2592780,2592709],"length":1,"stats":{"Line":3}},{"line":94,"address":[2592888,2591690,2591564,2592949,2592816,2592362,2592732],"length":1,"stats":{"Line":3}},{"line":97,"address":[2595122,2595104],"length":1,"stats":{"Line":3}},{"line":98,"address":[2616951,2617539,2617098,2617776,2617317,2618061,2616987],"length":1,"stats":{"Line":2}},{"line":99,"address":[2617019,2616972],"length":1,"stats":{"Line":2}},{"line":100,"address":[2617078,2617132],"length":1,"stats":{"Line":2}},{"line":101,"address":[2617297,2617354],"length":1,"stats":{"Line":2}},{"line":102,"address":[2617576,2617519],"length":1,"stats":{"Line":2}},{"line":105,"address":[2618042],"length":1,"stats":{"Line":1}},{"line":106,"address":[2618115],"length":1,"stats":{"Line":1}},{"line":108,"address":[2618201,2618262],"length":1,"stats":{"Line":2}},{"line":109,"address":[2618363,2618425],"length":1,"stats":{"Line":1}},{"line":111,"address":[2618398],"length":1,"stats":{"Line":1}},{"line":112,"address":[2618522,2618463],"length":1,"stats":{"Line":2}},{"line":113,"address":[2595141],"length":1,"stats":{"Line":2}}],"covered":74,"coverable":76},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-cli","src","client.rs"],"content":"//! API client for GitForge CLI\n\nuse anyhow::Result;\nuse reqwest::Client;\nuse serde::{de::DeserializeOwned, Serialize};\n\n/// GitForge API client\n#[derive(Clone)]\npub struct ApiClient {\n base_url: String,\n token: Option,\n http: Client,\n}\n\nimpl ApiClient {\n /// Create a new client\n pub fn new(base_url: &str, token: Option) -> Self {\n Self {\n base_url: base_url.to_string(),\n token,\n http: Client::new(),\n }\n }\n\n /// Set auth token\n pub fn with_token(mut self, token: String) -> Self {\n self.token = Some(token);\n self\n }\n\n /// GET request\n pub async fn get(&self, path: &str) -> Result {\n let url = format!(\"{}{}\", self.base_url, path);\n let mut req = self.http.get(&url);\n\n if let Some(token) = &self.token {\n req = req.header(\"Authorization\", format!(\"Bearer {}\", token));\n }\n\n let resp = req.send().await?;\n Ok(resp.json().await?)\n }\n\n /// POST request with JSON body\n pub async fn post(&self, path: &str, body: &T) -> Result {\n let url = format!(\"{}{}\", self.base_url, path);\n let mut req = self.http.post(&url).json(body);\n\n if let Some(token) = &self.token {\n req = req.header(\"Authorization\", format!(\"Bearer {}\", token));\n }\n\n let resp = req.send().await?;\n Ok(resp.json().await?)\n }\n\n /// DELETE request\n pub async fn delete(&self, path: &str) -> Result<()> {\n let url = format!(\"{}{}\", self.base_url, path);\n let mut req = self.http.delete(&url);\n\n if let Some(token) = &self.token {\n req = req.header(\"Authorization\", format!(\"Bearer {}\", token));\n }\n\n req.send().await?;\n Ok(())\n }\n}\n\npub use ApiClient as GitForgeClient;\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_api_client_new() {\n let client = ApiClient::new(\"http://localhost:8080\", None);\n assert_eq!(client.base_url, \"http://localhost:8080\");\n }\n\n #[test]\n fn test_api_client_with_token() {\n let client = ApiClient::new(\"http://localhost:8080\", None)\n .with_token(\"test-token\".to_string());\n // Token is set internally, verify client was created\n let client2 = ApiClient::new(\"http://localhost:8080\", Some(\"test-token\".to_string()));\n assert_eq!(client.token, client2.token);\n }\n\n #[test]\n fn test_api_client_clone() {\n let client = ApiClient::new(\"http://localhost:8080\", Some(\"token\".to_string()));\n let _cloned = client.clone();\n }\n\n #[test]\n fn test_api_client_base_url() {\n let client = ApiClient::new(\"http://localhost:9090\", None);\n assert_eq!(client.base_url, \"http://localhost:9090\");\n }\n\n #[test]\n fn test_api_client_token_none() {\n let client = ApiClient::new(\"http://localhost:8080\", None);\n assert!(client.token.is_none());\n }\n\n #[test]\n fn test_api_client_with_token_method() {\n let client = ApiClient::new(\"http://localhost:8080\", None).with_token(\"my-token\".to_string());\n assert_eq!(client.token, Some(\"my-token\".to_string()));\n }\n\n #[test]\n fn test_api_client_clone_preserves_url() {\n let client = ApiClient::new(\"http://custom:8080\", Some(\"token\".to_string()));\n let cloned = client.clone();\n assert_eq!(cloned.base_url, client.base_url);\n }\n\n #[test]\n fn test_api_client_clone_preserves_token() {\n let client = ApiClient::new(\"http://localhost:8080\", Some(\"secret-token\".to_string()));\n let cloned = client.clone();\n assert_eq!(cloned.token, client.token);\n }\n\n #[test]\n fn test_api_client_different_tokens() {\n let client1 = ApiClient::new(\"http://localhost:8080\", Some(\"token1\".to_string()));\n let client2 = ApiClient::new(\"http://localhost:8080\", Some(\"token2\".to_string()));\n assert_ne!(client1.token, client2.token);\n }\n\n #[test]\n fn test_api_client_with_empty_token() {\n let client = ApiClient::new(\"http://localhost:8080\", Some(\"\".to_string()));\n assert!(client.token.is_some());\n assert_eq!(client.token.unwrap(), \"\");\n }\n\n #[test]\n fn test_gitforge_client_is_api_client() {\n // GitForgeClient is an alias for ApiClient\n let client = GitForgeClient::new(\"http://localhost:8080\", None);\n assert_eq!(client.base_url, \"http://localhost:8080\");\n }\n}\n","traces":[{"line":17,"address":[9601156,9600880,9601131],"length":1,"stats":{"Line":11}},{"line":19,"address":[9600912],"length":1,"stats":{"Line":11}},{"line":21,"address":[9601000],"length":1,"stats":{"Line":11}},{"line":26,"address":[9600850,9600688],"length":1,"stats":{"Line":2}},{"line":27,"address":[9600803,9600715],"length":1,"stats":{"Line":4}},{"line":28,"address":[9600830],"length":1,"stats":{"Line":2}},{"line":32,"address":[],"length":0,"stats":{"Line":0}},{"line":33,"address":[],"length":0,"stats":{"Line":0}},{"line":34,"address":[],"length":0,"stats":{"Line":0}},{"line":36,"address":[],"length":0,"stats":{"Line":0}},{"line":37,"address":[],"length":0,"stats":{"Line":0}},{"line":40,"address":[],"length":0,"stats":{"Line":0}},{"line":41,"address":[],"length":0,"stats":{"Line":0}},{"line":45,"address":[],"length":0,"stats":{"Line":0}},{"line":46,"address":[],"length":0,"stats":{"Line":0}},{"line":47,"address":[],"length":0,"stats":{"Line":0}},{"line":49,"address":[],"length":0,"stats":{"Line":0}},{"line":50,"address":[],"length":0,"stats":{"Line":0}},{"line":53,"address":[],"length":0,"stats":{"Line":0}},{"line":54,"address":[],"length":0,"stats":{"Line":0}},{"line":58,"address":[9265232,9265410,9265263,9265461,9266220,9266303],"length":1,"stats":{"Line":0}},{"line":59,"address":[9265375,9265508],"length":1,"stats":{"Line":0}},{"line":60,"address":[9265679],"length":1,"stats":{"Line":0}},{"line":62,"address":[9265758,9266183],"length":1,"stats":{"Line":0}},{"line":63,"address":[9265953,9266188,9266201,9265816],"length":1,"stats":{"Line":0}},{"line":66,"address":[9266337,9265440,9265922,9266862,9266236],"length":1,"stats":{"Line":0}},{"line":67,"address":[9266758],"length":1,"stats":{"Line":0}}],"covered":6,"coverable":27},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-cli","src","config.rs"],"content":"//! CLI configuration management\n\nuse anyhow::Result;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\n/// CLI configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Config {\n /// Server URL\n pub server_url: String,\n\n /// Auth token\n pub token: Option,\n\n /// Local data directory\n pub local_data_dir: PathBuf,\n\n /// Default organization\n pub organization: Option,\n\n /// Editor for multi-line input\n pub editor: Option,\n\n /// Output format (json, yaml, table)\n pub output_format: OutputFormat,\n\n /// Feature flags\n pub features: HashMap,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]\npub enum OutputFormat {\n #[default]\n Table,\n Json,\n Yaml,\n}\n\nimpl Config {\n /// Load configuration from default locations\n pub fn load() -> Result {\n let config_path = Self::config_path()?;\n\n if config_path.exists() {\n let contents = std::fs::read_to_string(&config_path)?;\n let config: Config = toml::from_str(&contents)?;\n Ok(config)\n } else {\n // Return default config\n Ok(Self::default())\n }\n }\n\n /// Save configuration\n pub fn save(&self) -> Result<()> {\n let config_path = Self::config_path()?;\n if let Some(parent) = config_path.parent() {\n std::fs::create_dir_all(parent)?;\n }\n let contents = toml::to_string_pretty(self)?;\n std::fs::write(config_path, contents)?;\n Ok(())\n }\n\n /// Get config file path\n fn config_path() -> Result {\n let base = dirs::config_dir()\n .ok_or_else(|| anyhow::anyhow!(\"could not find config directory\"))?;\n Ok(base.join(\"gitforge\").join(\"config.toml\"))\n }\n}\n\nimpl Default for Config {\n fn default() -> Self {\n Self {\n server_url: \"http://localhost:8080\".to_string(),\n token: None,\n local_data_dir: dirs::data_local_dir()\n .unwrap_or_else(|| PathBuf::from(\".\"))\n .join(\"gitforge\"),\n organization: None,\n editor: None,\n output_format: OutputFormat::Table,\n features: HashMap::new(),\n }\n }\n}\n\nimpl Config {\n /// Get the API base URL\n pub fn api_url(&self) -> String {\n format!(\"{}/api\", self.server_url)\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_config_default() {\n let config = Config::default();\n assert_eq!(config.server_url, \"http://localhost:8080\");\n assert!(config.token.is_none());\n assert_eq!(config.output_format, OutputFormat::Table);\n }\n\n #[test]\n fn test_config_api_url() {\n let config = Config {\n server_url: \"https://gitforge.example.com\".to_string(),\n ..Default::default()\n };\n assert_eq!(config.api_url(), \"https://gitforge.example.com/api\");\n }\n\n #[test]\n fn test_output_format_default() {\n assert_eq!(OutputFormat::default(), OutputFormat::Table);\n }\n\n #[test]\n fn test_config_with_token() {\n let mut config = Config::default();\n config.token = Some(\"test-token\".to_string());\n assert_eq!(config.token.as_ref().unwrap(), \"test-token\");\n }\n\n #[test]\n fn test_config_with_features() {\n let mut config = Config::default();\n config.features.insert(\"sync\".to_string(), true);\n assert_eq!(config.features.get(\"sync\"), Some(&true));\n }\n\n #[test]\n fn test_output_format_variants() {\n assert!(matches!(OutputFormat::Table, OutputFormat::Table));\n assert!(matches!(OutputFormat::Json, OutputFormat::Json));\n assert!(matches!(OutputFormat::Yaml, OutputFormat::Yaml));\n }\n\n #[test]\n fn test_output_format_serialize() {\n let fmt = OutputFormat::Json;\n let serialized = serde_json::to_string(&fmt).unwrap();\n assert_eq!(serialized, \"\\\"Json\\\"\");\n }\n\n #[test]\n fn test_output_format_deserialize() {\n let fmt: OutputFormat = serde_json::from_str(\"\\\"Yaml\\\"\").unwrap();\n assert!(matches!(fmt, OutputFormat::Yaml));\n }\n\n #[test]\n fn test_config_serialize() {\n let config = Config::default();\n let serialized = serde_json::to_string(&config).unwrap();\n assert!(serialized.contains(\"http://localhost:8080\"));\n }\n\n #[test]\n fn test_config_deserialize() {\n let json = r#\"{\n \"server_url\": \"https://custom.example.com\",\n \"token\": null,\n \"local_data_dir\": \"/tmp/gitforge\",\n \"organization\": null,\n \"editor\": null,\n \"output_format\": \"Json\",\n \"features\": {}\n }\"#;\n let config: Config = serde_json::from_str(json).unwrap();\n assert_eq!(config.server_url, \"https://custom.example.com\");\n assert!(matches!(config.output_format, OutputFormat::Json));\n }\n\n #[test]\n fn test_config_clone() {\n let config = Config::default();\n let cloned = config.clone();\n assert_eq!(config.server_url, cloned.server_url);\n }\n\n #[test]\n fn test_config_local_data_dir_default() {\n let config = Config::default();\n assert!(config.local_data_dir.to_string_lossy().contains(\"gitforge\"));\n }\n\n #[test]\n fn test_config_organization() {\n let mut config = Config::default();\n config.organization = Some(\"my-org\".to_string());\n assert_eq!(config.organization.unwrap(), \"my-org\");\n }\n\n #[test]\n fn test_config_editor() {\n let mut config = Config::default();\n config.editor = Some(\"vim\".to_string());\n assert_eq!(config.editor.unwrap(), \"vim\");\n }\n}\n","traces":[{"line":43,"address":[9645463,9645471,9644448],"length":1,"stats":{"Line":2}},{"line":44,"address":[9644465],"length":1,"stats":{"Line":2}},{"line":46,"address":[9644625,9644693],"length":1,"stats":{"Line":6}},{"line":47,"address":[9645469,9644742,9644788],"length":1,"stats":{"Line":0}},{"line":48,"address":[9644962,9645041],"length":1,"stats":{"Line":0}},{"line":49,"address":[9645394],"length":1,"stats":{"Line":0}},{"line":52,"address":[9644722,9644754],"length":1,"stats":{"Line":6}},{"line":57,"address":[9646581,9646622,9645488],"length":1,"stats":{"Line":0}},{"line":58,"address":[9645508],"length":1,"stats":{"Line":0}},{"line":59,"address":[9645775,9645707],"length":1,"stats":{"Line":0}},{"line":60,"address":[9645925,9645885],"length":1,"stats":{"Line":0}},{"line":62,"address":[9645910,9646065,9646592],"length":1,"stats":{"Line":0}},{"line":63,"address":[9646541,9646252,9646403],"length":1,"stats":{"Line":0}},{"line":64,"address":[9646498],"length":1,"stats":{"Line":0}},{"line":68,"address":[9644425,9644419,9643920],"length":1,"stats":{"Line":2}},{"line":69,"address":[9643963,9644030,9643937],"length":1,"stats":{"Line":3}},{"line":70,"address":[9644017,9643948],"length":1,"stats":{"Line":3}},{"line":71,"address":[9644104,9644172,9644275],"length":1,"stats":{"Line":10}},{"line":76,"address":[9643592,9643516,9642976],"length":1,"stats":{"Line":4}},{"line":78,"address":[9642993],"length":1,"stats":{"Line":2}},{"line":80,"address":[9643034,9643126],"length":1,"stats":{"Line":10}},{"line":86,"address":[9643255],"length":1,"stats":{"Line":5}},{"line":93,"address":[9646656],"length":1,"stats":{"Line":1}},{"line":94,"address":[9646674],"length":1,"stats":{"Line":1}}],"covered":14,"coverable":24},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-cli","src","main.rs"],"content":"//! GitForge CLI\n//!\n//! Local-first Git platform client for GitForge.\n\nuse clap::Parser;\nuse clap::Subcommand;\nuse anyhow::Result;\nuse std::path::PathBuf;\n\nmod config;\nmod client;\nmod sync;\n\npub use config::Config;\npub use client::GitForgeClient;\n\n#[derive(Parser, Debug)]\n#[command(name = \"gitforge\")]\n#[command(version = \"0.1.0\")]\n#[command(about = \"GitForge CLI - Local-first Git platform client\")]\npub struct Cli {\n #[arg(short, long)]\n verbose: bool,\n #[arg(short, long)]\n server: Option,\n #[arg(short, long)]\n token: Option,\n #[command(subcommand)]\n command: Commands,\n}\n\n#[derive(Subcommand, Debug)]\nenum Commands {\n /// Authentication commands\n Auth {\n #[arg(short, long)]\n login: Option,\n #[arg(short, long)]\n logout: bool,\n #[arg(short, long)]\n status: bool,\n },\n /// Repository commands\n Repo {\n #[arg(short, long)]\n list: bool,\n #[arg(short, long)]\n create: Option,\n #[arg(short, long)]\n info: Option,\n #[arg(short, long)]\n delete: Option,\n },\n /// Pipeline commands\n Pipeline {\n #[arg(short, long)]\n list: bool,\n #[arg(short, long)]\n show: Option,\n #[arg(short, long)]\n run: Option,\n #[arg(short, long)]\n watch: Option,\n },\n /// Runner commands\n Runner {\n #[arg(short, long)]\n list: bool,\n #[arg(short, long)]\n info: Option,\n #[arg(short, long)]\n register: Option,\n #[arg(short, long)]\n capacity: Option,\n },\n /// Sync commands\n Sync {\n #[arg(short, long)]\n status: bool,\n #[arg(short, long)]\n push: bool,\n #[arg(short, long)]\n pull: bool,\n #[arg(short, long)]\n init: Option,\n },\n}\n\n/// Run the CLI command handler (extracted for testing)\npub async fn run_cli(cli: Cli) -> Result<()> {\n let config = Config::load().unwrap_or_default();\n let server = cli.server.unwrap_or_else(|| config.server_url.clone());\n\n match &cli.command {\n Commands::Auth { login, logout, status } => {\n if let Some(username) = login {\n tracing::info!(\"Logging in as {} to {}\", username, server);\n println!(\"Login successful! (API not yet implemented)\");\n } else if *logout {\n tracing::info!(\"Logged out\");\n } else if *status {\n if config.token.is_some() {\n println!(\"Authenticated to {}\", config.server_url);\n } else {\n println!(\"Not authenticated. Run `gitforge auth login` to authenticate.\");\n }\n }\n }\n Commands::Repo { list, create, info, delete } => {\n if *list {\n println!(\"Repositories:\\n (API not yet wired)\");\n } else if let Some(name) = create {\n tracing::info!(\"Creating repository {}\", name);\n println!(\"Repository '{}' created (API not yet wired)\", name);\n } else if let Some(name) = info {\n println!(\"Repository '{}' info (API not yet wired)\", name);\n } else if let Some(name) = delete {\n tracing::warn!(\"Deleting repository {}\", name);\n println!(\"Repository '{}' deleted (API not yet wired)\", name);\n }\n }\n Commands::Pipeline { list, show, run, watch } => {\n if *list {\n println!(\"Pipelines:\\n (API not yet wired)\");\n } else if let Some(id) = show {\n println!(\"Pipeline {} (API not yet wired)\", id);\n } else if let Some(id) = run {\n println!(\"Pipeline {} triggered (API not yet wired)\", id);\n } else if let Some(id) = watch {\n println!(\"Watching pipeline {} (not yet implemented)\", id);\n }\n }\n Commands::Runner { list, info, register, capacity } => {\n if *list {\n println!(\"Runners:\\n (API not yet wired)\");\n } else if let Some(id) = info {\n println!(\"Runner {} (API not yet wired)\", id);\n } else if let Some(name) = register {\n let cap = capacity.unwrap_or(2);\n println!(\"Runner '{}' registered with capacity {} (API not yet wired)\", name, cap);\n }\n }\n Commands::Sync { status, push, pull, init } => {\n let local_dir = config.local_data_dir.clone();\n let sync_client = sync::SyncClient::with_real_client(local_dir.clone());\n\n if let Err(e) = sync_client.init().await {\n tracing::warn!(\"Failed to initialize sync: {}\", e);\n }\n\n if *status {\n let sync_status = sync_client.status().await;\n println!(\"Local storage: {}\", local_dir.display());\n println!(\"Sync status: {:?}\", sync_status);\n } else if *push {\n if let Some(token) = &config.token {\n match sync_client.push(&config.api_url(), token).await {\n Ok(response) => {\n println!(\"Push successful!\");\n println!(\" Remote revision: {}\", response.remote_rev);\n if response.conflicts.is_empty() {\n println!(\" No conflicts\");\n } else {\n println!(\" Conflicts: {}\", response.conflicts.join(\", \"));\n }\n }\n Err(e) => {\n tracing::error!(\"Push failed: {}\", e);\n println!(\"Push failed: {}\", e);\n }\n }\n } else {\n println!(\"Not authenticated. Run `gitforge auth login` first.\");\n }\n } else if *pull {\n if let Some(token) = &config.token {\n match sync_client.pull(&config.api_url(), token).await {\n Ok(response) => {\n println!(\"Pull successful!\");\n println!(\" Remote revision: {}\", response.remote_rev);\n println!(\" Repositories synced: {}\", response.repos.len());\n println!(\" Pipelines synced: {}\", response.pipelines.len());\n }\n Err(e) => {\n tracing::error!(\"Pull failed: {}\", e);\n println!(\"Pull failed: {}\", e);\n }\n }\n } else {\n println!(\"Not authenticated. Run `gitforge auth login` first.\");\n }\n } else if let Some(directory) = init {\n let path = PathBuf::from(directory);\n if path.exists() {\n anyhow::bail!(\"Directory {} already exists\", directory);\n }\n std::fs::create_dir_all(&path)?;\n let init_client = sync::SyncClient::with_real_client(path.clone());\n init_client.init().await?;\n println!(\"Initialized local storage at {}\", path.display());\n }\n }\n }\n Ok(())\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n let cli = Cli::parse();\n\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::from_default_env()\n .add_directive(if cli.verbose { tracing::Level::DEBUG.into() } else { tracing::Level::INFO.into() })\n )\n .init();\n\n run_cli(cli).await\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n // Helper to create a Cli with given command\n fn test_cli(command: Commands) -> Cli {\n Cli {\n verbose: false,\n server: None,\n token: None,\n command,\n }\n }\n\n #[tokio::test]\n async fn test_run_cli_auth_status_not_authenticated() {\n let cli = test_cli(Commands::Auth {\n login: None,\n logout: false,\n status: true,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_auth_login() {\n let cli = test_cli(Commands::Auth {\n login: Some(\"testuser\".to_string()),\n logout: false,\n status: false,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_auth_logout() {\n let cli = test_cli(Commands::Auth {\n login: None,\n logout: true,\n status: false,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_repo_list() {\n let cli = test_cli(Commands::Repo {\n list: true,\n create: None,\n info: None,\n delete: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_repo_create() {\n let cli = test_cli(Commands::Repo {\n list: false,\n create: Some(\"test-repo\".to_string()),\n info: None,\n delete: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_repo_info() {\n let cli = test_cli(Commands::Repo {\n list: false,\n create: None,\n info: Some(\"my-repo\".to_string()),\n delete: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_repo_delete() {\n let cli = test_cli(Commands::Repo {\n list: false,\n create: None,\n info: None,\n delete: Some(\"old-repo\".to_string()),\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_pipeline_list() {\n let cli = test_cli(Commands::Pipeline {\n list: true,\n show: None,\n run: None,\n watch: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_pipeline_show() {\n let cli = test_cli(Commands::Pipeline {\n list: false,\n show: Some(\"pipeline-123\".to_string()),\n run: None,\n watch: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_pipeline_run() {\n let cli = test_cli(Commands::Pipeline {\n list: false,\n show: None,\n run: Some(\"pipeline-456\".to_string()),\n watch: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_pipeline_watch() {\n let cli = test_cli(Commands::Pipeline {\n list: false,\n show: None,\n run: None,\n watch: Some(\"pipeline-789\".to_string()),\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_runner_list() {\n let cli = test_cli(Commands::Runner {\n list: true,\n info: None,\n register: None,\n capacity: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_runner_info() {\n let cli = test_cli(Commands::Runner {\n list: false,\n info: Some(\"runner-abc\".to_string()),\n register: None,\n capacity: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_runner_register() {\n let cli = test_cli(Commands::Runner {\n list: false,\n info: None,\n register: Some(\"my-runner\".to_string()),\n capacity: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_runner_register_with_capacity() {\n let cli = test_cli(Commands::Runner {\n list: false,\n info: None,\n register: Some(\"big-runner\".to_string()),\n capacity: Some(8),\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_sync_status() {\n let cli = test_cli(Commands::Sync {\n status: true,\n push: false,\n pull: false,\n init: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_run_cli_sync_init_creates_directory() {\n let temp_dir = std::env::temp_dir();\n let test_dir = temp_dir.join(\"gitforge-test-init\").to_str().unwrap().to_string();\n\n // Clean up if exists\n let _ = std::fs::remove_dir_all(&test_dir);\n\n let cli = test_cli(Commands::Sync {\n status: false,\n push: false,\n pull: false,\n init: Some(test_dir.clone()),\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok());\n\n // Verify directory was created\n assert!(std::path::Path::new(&test_dir).exists());\n\n // Clean up\n let _ = std::fs::remove_dir_all(&test_dir);\n }\n\n #[tokio::test]\n async fn test_run_cli_sync_init_fails_if_exists() {\n let temp_dir = std::env::temp_dir();\n let test_dir = temp_dir.join(\"gitforge-test-init-exists\").to_str().unwrap().to_string();\n\n // Create directory first\n std::fs::create_dir_all(&test_dir).unwrap();\n\n let cli = test_cli(Commands::Sync {\n status: false,\n push: false,\n pull: false,\n init: Some(test_dir.clone()),\n });\n let result = run_cli(cli).await;\n assert!(result.is_err()); // Should fail because directory exists\n\n // Clean up\n let _ = std::fs::remove_dir_all(&test_dir);\n }\n\n #[tokio::test]\n async fn test_run_cli_sync_push_not_authenticated() {\n let cli = test_cli(Commands::Sync {\n status: false,\n push: true,\n pull: false,\n init: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok()); // Just prints message, doesn't fail\n }\n\n #[tokio::test]\n async fn test_run_cli_sync_pull_not_authenticated() {\n let cli = test_cli(Commands::Sync {\n status: false,\n push: false,\n pull: true,\n init: None,\n });\n let result = run_cli(cli).await;\n assert!(result.is_ok()); // Just prints message, doesn't fail\n }\n}\n\n","traces":[{"line":90,"address":[9339822,9328936,9328873,9328915,9328830,9328717,9328656,9338020,9348321,9328894],"length":1,"stats":{"Line":5}},{"line":91,"address":[9328820,9329013],"length":1,"stats":{"Line":3}},{"line":92,"address":[9329043,9350992,9351009],"length":1,"stats":{"Line":12}},{"line":94,"address":[9329174],"length":1,"stats":{"Line":3}},{"line":95,"address":[9329239],"length":1,"stats":{"Line":2}},{"line":96,"address":[9329774,9329308],"length":1,"stats":{"Line":2}},{"line":97,"address":[9329782,9330301,9329890],"length":1,"stats":{"Line":3}},{"line":98,"address":[9331468,9330256],"length":1,"stats":{"Line":2}},{"line":99,"address":[9329818],"length":1,"stats":{"Line":1}},{"line":100,"address":[9331537,9331820],"length":1,"stats":{"Line":2}},{"line":101,"address":[9331530],"length":1,"stats":{"Line":1}},{"line":102,"address":[9331576],"length":1,"stats":{"Line":1}},{"line":103,"address":[9331723,9331657],"length":1,"stats":{"Line":0}},{"line":105,"address":[9331614,9331707],"length":1,"stats":{"Line":2}},{"line":109,"address":[9329358],"length":1,"stats":{"Line":1}},{"line":110,"address":[9329442],"length":1,"stats":{"Line":1}},{"line":111,"address":[9333138,9336557],"length":1,"stats":{"Line":2}},{"line":112,"address":[9333184,9333104],"length":1,"stats":{"Line":2}},{"line":113,"address":[9333192,9333276,9333687],"length":1,"stats":{"Line":3}},{"line":114,"address":[9333650,9334700],"length":1,"stats":{"Line":2}},{"line":115,"address":[9333228,9334798],"length":1,"stats":{"Line":2}},{"line":116,"address":[9334814,9334886],"length":1,"stats":{"Line":2}},{"line":117,"address":[9334849,9334984],"length":1,"stats":{"Line":2}},{"line":118,"address":[9334992,9335438],"length":1,"stats":{"Line":2}},{"line":119,"address":[9336451,9335401],"length":1,"stats":{"Line":2}},{"line":122,"address":[9329465],"length":1,"stats":{"Line":2}},{"line":123,"address":[9329549],"length":1,"stats":{"Line":2}},{"line":124,"address":[9336615,9337182],"length":1,"stats":{"Line":2}},{"line":125,"address":[9336661,9336581],"length":1,"stats":{"Line":2}},{"line":126,"address":[9336677,9336749],"length":1,"stats":{"Line":2}},{"line":127,"address":[9336847,9336712],"length":1,"stats":{"Line":3}},{"line":128,"address":[9336935,9336863],"length":1,"stats":{"Line":2}},{"line":129,"address":[9337033,9336898],"length":1,"stats":{"Line":2}},{"line":130,"address":[9337049],"length":1,"stats":{"Line":1}},{"line":133,"address":[9329572],"length":1,"stats":{"Line":1}},{"line":134,"address":[9329656],"length":1,"stats":{"Line":1}},{"line":135,"address":[9337240,9337730],"length":1,"stats":{"Line":2}},{"line":136,"address":[9337206,9337286],"length":1,"stats":{"Line":2}},{"line":137,"address":[9337374,9337302],"length":1,"stats":{"Line":2}},{"line":138,"address":[9337480,9337337],"length":1,"stats":{"Line":2}},{"line":139,"address":[9337488],"length":1,"stats":{"Line":1}},{"line":140,"address":[9337538],"length":1,"stats":{"Line":1}},{"line":143,"address":[9329687],"length":1,"stats":{"Line":3}},{"line":144,"address":[9329739],"length":1,"stats":{"Line":1}},{"line":145,"address":[9337835,9337754],"length":1,"stats":{"Line":5}},{"line":147,"address":[9337865,9328860,9337950,9338054],"length":1,"stats":{"Line":5}},{"line":148,"address":[9338314,9338422,9338817],"length":1,"stats":{"Line":0}},{"line":151,"address":[9338350],"length":1,"stats":{"Line":3}},{"line":152,"address":[9339858,9341878,9328881,9341800],"length":1,"stats":{"Line":2}},{"line":153,"address":[9342090],"length":1,"stats":{"Line":1}},{"line":154,"address":[9342315],"length":1,"stats":{"Line":1}},{"line":155,"address":[9339836],"length":1,"stats":{"Line":1}},{"line":156,"address":[9339913,9341369],"length":1,"stats":{"Line":1}},{"line":157,"address":[9328902,9341464,9341377,9341585,9342436],"length":1,"stats":{"Line":0}},{"line":158,"address":[9342748],"length":1,"stats":{"Line":0}},{"line":159,"address":[9342796,9342887],"length":1,"stats":{"Line":0}},{"line":160,"address":[9342898],"length":1,"stats":{"Line":0}},{"line":161,"address":[9343018],"length":1,"stats":{"Line":0}},{"line":162,"address":[9343392,9343092],"length":1,"stats":{"Line":0}},{"line":164,"address":[9343146,9343057],"length":1,"stats":{"Line":0}},{"line":167,"address":[9342701],"length":1,"stats":{"Line":0}},{"line":168,"address":[9343485,9343892,9342717],"length":1,"stats":{"Line":0}},{"line":169,"address":[9343859,9344897],"length":1,"stats":{"Line":0}},{"line":173,"address":[9341418,9341768],"length":1,"stats":{"Line":2}},{"line":175,"address":[9339891],"length":1,"stats":{"Line":2}},{"line":176,"address":[9340036,9340938],"length":1,"stats":{"Line":1}},{"line":177,"address":[9340946,9341033,9328923,9341154,9345012],"length":1,"stats":{"Line":0}},{"line":178,"address":[9345368],"length":1,"stats":{"Line":0}},{"line":179,"address":[9345480,9345571],"length":1,"stats":{"Line":0}},{"line":180,"address":[9345582],"length":1,"stats":{"Line":0}},{"line":181,"address":[9345710],"length":1,"stats":{"Line":0}},{"line":182,"address":[9345861],"length":1,"stats":{"Line":0}},{"line":184,"address":[9345321],"length":1,"stats":{"Line":0}},{"line":185,"address":[9346115,9346522,9345337],"length":1,"stats":{"Line":0}},{"line":186,"address":[9346489,9347515],"length":1,"stats":{"Line":0}},{"line":190,"address":[9341337,9340987],"length":1,"stats":{"Line":2}},{"line":192,"address":[9340110,9339987],"length":1,"stats":{"Line":2}},{"line":193,"address":[9340125],"length":1,"stats":{"Line":1}},{"line":194,"address":[9340180,9340277],"length":1,"stats":{"Line":2}},{"line":195,"address":[9340758,9340342],"length":1,"stats":{"Line":2}},{"line":197,"address":[9340380,9340312,9340721],"length":1,"stats":{"Line":2}},{"line":198,"address":[9340495],"length":1,"stats":{"Line":1}},{"line":199,"address":[9340555,9347618,9340640,9328944,9348458],"length":1,"stats":{"Line":2}},{"line":200,"address":[9347931],"length":1,"stats":{"Line":1}},{"line":204,"address":[9331489],"length":1,"stats":{"Line":1}},{"line":208,"address":[9491528,9491534,9491088],"length":1,"stats":{"Line":0}},{"line":209,"address":[9327826],"length":1,"stats":{"Line":0}},{"line":211,"address":[9327930],"length":1,"stats":{"Line":0}},{"line":213,"address":[9328146,9327983],"length":1,"stats":{"Line":0}},{"line":214,"address":[9328364,9328002,9328106,9328034,9328154],"length":1,"stats":{"Line":0}},{"line":218,"address":[9327873,9328423,9328279],"length":1,"stats":{"Line":0}}],"covered":64,"coverable":91},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-cli","src","sync.rs"],"content":"//! Cloud sync protocol for GitForge CLI\n//!\n//! Implements local-first sync with conflict resolution:\n//! - Push: Upload local changes to cloud\n//! - Pull: Download cloud changes to local\n//! - Conflict resolution: Last-write-wins with local priority\n\nuse anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::PathBuf;\n\n/// HTTP client trait for sync operations (allows mocking in tests)\n#[async_trait]\npub trait HttpClient: Send + Sync {\n async fn post_json Deserialize<'de> + Send>(\n &self,\n url: &str,\n token: &str,\n payload: &T,\n ) -> Result;\n async fn get_json Deserialize<'de> + Send>(\n &self,\n url: &str,\n token: &str,\n ) -> Result;\n}\n\n/// Real HTTP client using reqwest\npub struct RealHttpClient {\n client: reqwest::Client,\n}\n\nimpl RealHttpClient {\n pub fn new() -> Self {\n Self {\n client: reqwest::Client::new(),\n }\n }\n}\n\nimpl Default for RealHttpClient {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl HttpClient for RealHttpClient {\n async fn post_json Deserialize<'de> + Send>(\n &self,\n url: &str,\n token: &str,\n payload: &T,\n ) -> Result {\n let response = self\n .client\n .post(url)\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .json(payload)\n .send()\n .await\n .context(\"failed to push to cloud\")?;\n\n if !response.status().is_success() {\n anyhow::bail!(\"Push failed: {}\", response.status());\n }\n\n let result = response.json().await?;\n Ok(result)\n }\n\n async fn get_json Deserialize<'de> + Send>(\n &self,\n url: &str,\n token: &str,\n ) -> Result {\n let response = self\n .client\n .get(url)\n .header(\"Authorization\", format!(\"Bearer {}\", token))\n .send()\n .await\n .context(\"failed to pull from cloud\")?;\n\n if !response.status().is_success() {\n anyhow::bail!(\"Pull failed: {}\", response.status());\n }\n\n let result = response.json().await?;\n Ok(result)\n }\n}\n\n/// Mock HTTP client for testing\n#[allow(dead_code)]\npub struct MockHttpClient {\n pub push_response: Option>,\n pub pull_response: Option>,\n}\n\n#[allow(dead_code)]\nimpl MockHttpClient {\n pub fn new() -> Self {\n Self {\n push_response: None,\n pull_response: None,\n }\n }\n\n pub fn with_push_response(mut self, response: PushResponse) -> Self {\n self.push_response = Some(Ok(response));\n self\n }\n\n pub fn with_pull_response(mut self, response: PullResponse) -> Self {\n self.pull_response = Some(Ok(response));\n self\n }\n\n pub fn with_push_error(mut self, err: anyhow::Error) -> Self {\n self.push_response = Some(Err(err));\n self\n }\n}\n\nimpl Default for MockHttpClient {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl HttpClient for MockHttpClient {\n async fn post_json Deserialize<'de> + Send>(\n &self,\n _url: &str,\n _token: &str,\n _payload: &T,\n ) -> Result {\n match &self.push_response {\n Some(Ok(resp)) => {\n let json = serde_json::to_string(resp).unwrap();\n Ok(serde_json::from_str(&json).unwrap())\n }\n Some(Err(e)) => Err(anyhow::anyhow!(\"{}\", e)),\n None => anyhow::bail!(\"mock push not configured\"),\n }\n }\n\n async fn get_json Deserialize<'de> + Send>(\n &self,\n _url: &str,\n _token: &str,\n ) -> Result {\n match &self.pull_response {\n Some(Ok(resp)) => {\n let json = serde_json::to_string(resp).unwrap();\n Ok(serde_json::from_str(&json).unwrap())\n }\n Some(Err(e)) => Err(anyhow::anyhow!(\"{}\", e)),\n None => anyhow::bail!(\"mock pull not configured\"),\n }\n }\n}\n\n/// Sync metadata for tracking state\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct SyncMetadata {\n /// Last sync timestamp (RFC3339)\n pub last_push: Option,\n /// Last sync timestamp (RFC3339)\n pub last_pull: Option,\n /// Local revision counter\n pub local_rev: u64,\n /// Remote revision counter\n pub remote_rev: u64,\n /// Sync status\n pub status: SyncStatus,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]\n#[serde(rename_all = \"lowercase\")]\npub enum SyncStatus {\n #[default]\n InSync,\n PendingPush,\n PendingPull,\n Conflict,\n}\n\n/// Local sync state\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct LocalState {\n /// Repositories (name -> metadata)\n pub repos: HashMap,\n /// Pipelines (id -> metadata)\n pub pipelines: HashMap,\n /// Last modified timestamp\n pub updated_at: String,\n}\n\n/// Repository sync state\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RepoState {\n pub id: String,\n pub name: String,\n pub local_path: Option,\n pub synced_at: Option,\n pub local_rev: u64,\n}\n\n/// Pipeline sync state\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineState {\n pub id: String,\n pub repo_id: String,\n pub name: String,\n pub definition: String,\n pub synced_at: Option,\n pub local_rev: u64,\n}\n\n/// Cloud sync protocol client\npub struct SyncClient {\n local_dir: PathBuf,\n metadata: tokio::sync::RwLock,\n state: tokio::sync::RwLock,\n http_client: C,\n}\n\nimpl SyncClient {\n /// Create a new sync client with the given HTTP client\n #[allow(dead_code)]\n pub fn new(local_dir: PathBuf, http_client: C) -> Self {\n Self {\n local_dir,\n metadata: tokio::sync::RwLock::new(SyncMetadata::default()),\n state: tokio::sync::RwLock::new(LocalState::default()),\n http_client,\n }\n }\n}\n\nimpl SyncClient {\n /// Create a new sync client with a real HTTP client\n pub fn with_real_client(local_dir: PathBuf) -> Self {\n Self {\n local_dir: local_dir.clone(),\n metadata: tokio::sync::RwLock::new(SyncMetadata::default()),\n state: tokio::sync::RwLock::new(LocalState::default()),\n http_client: RealHttpClient::new(),\n }\n }\n}\n\nimpl SyncClient {\n /// Initialize sync directory\n pub async fn init(&self) -> Result<()> {\n let sync_dir = self.local_dir.join(\".gitforge\");\n fs::create_dir_all(&sync_dir).context(\"failed to create sync directory\")?;\n\n let metadata_path = sync_dir.join(\"metadata.json\");\n let state_path = sync_dir.join(\"state.json\");\n\n // Load existing metadata if present\n if metadata_path.exists() {\n let contents = fs::read_to_string(&metadata_path)?;\n let metadata: SyncMetadata = serde_json::from_str(&contents)?;\n *self.metadata.write().await = metadata;\n }\n\n // Load existing state if present\n if state_path.exists() {\n let contents = fs::read_to_string(&state_path)?;\n let state: LocalState = serde_json::from_str(&contents)?;\n *self.state.write().await = state;\n }\n\n Ok(())\n }\n\n /// Get sync directory path\n pub fn sync_dir(&self) -> PathBuf {\n self.local_dir.join(\".gitforge\")\n }\n\n /// Save metadata to disk\n async fn save_metadata(&self) -> Result<()> {\n let sync_dir = self.sync_dir();\n fs::create_dir_all(&sync_dir)?;\n let metadata_path = sync_dir.join(\"metadata.json\");\n let metadata = self.metadata.read().await;\n let contents = serde_json::to_string_pretty(&*metadata)?;\n fs::write(metadata_path, contents)?;\n Ok(())\n }\n\n /// Save state to disk\n async fn save_state(&self) -> Result<()> {\n let sync_dir = self.sync_dir();\n fs::create_dir_all(&sync_dir)?;\n let state_path = sync_dir.join(\"state.json\");\n let state = self.state.read().await;\n let contents = serde_json::to_string_pretty(&*state)?;\n fs::write(state_path, contents)?;\n Ok(())\n }\n\n /// Get current sync status\n pub async fn status(&self) -> SyncStatus {\n self.metadata.read().await.status.clone()\n }\n\n /// Mark local changes pending push\n pub async fn mark_pending_push(&self) -> Result<()> {\n let mut metadata = self.metadata.write().await;\n metadata.status = SyncStatus::PendingPush;\n metadata.local_rev += 1;\n drop(metadata);\n self.save_metadata().await\n }\n\n /// Push local state to cloud\n pub async fn push(&self, api_url: &str, token: &str) -> Result {\n let state = {\n self.state.read().await.clone()\n };\n let metadata = {\n self.metadata.read().await.clone()\n };\n\n // Prepare push payload\n let payload = PushPayload {\n local_rev: metadata.local_rev,\n repos: state.repos,\n pipelines: state.pipelines,\n timestamp: chrono::Utc::now().to_rfc3339(),\n };\n\n // POST to cloud sync endpoint\n let url = format!(\"{}/sync/push\", api_url);\n let push_response: PushResponse = self.http_client\n .post_json(&url, token, &payload)\n .await\n .context(\"failed to push to cloud\")?;\n\n // Update metadata\n let mut metadata = self.metadata.write().await;\n metadata.last_push = Some(chrono::Utc::now().to_rfc3339());\n metadata.remote_rev = push_response.remote_rev;\n metadata.status = SyncStatus::InSync;\n drop(metadata);\n self.save_metadata().await?;\n\n Ok(push_response)\n }\n\n /// Pull cloud state to local\n pub async fn pull(&self, api_url: &str, token: &str) -> Result {\n // GET from cloud sync endpoint\n let url = format!(\"{}/sync/pull\", api_url);\n let pull_response: PullResponse = self.http_client\n .get_json(&url, token)\n .await\n .context(\"failed to pull from cloud\")?;\n\n // Update local state\n {\n let mut state = self.state.write().await;\n for (name, repo) in &pull_response.repos {\n state.repos.insert(name.clone(), repo.clone());\n }\n for (id, pipeline) in &pull_response.pipelines {\n state.pipelines.insert(id.clone(), pipeline.clone());\n }\n state.updated_at = chrono::Utc::now().to_rfc3339();\n }\n\n // Update metadata\n let mut metadata = self.metadata.write().await;\n metadata.last_pull = Some(chrono::Utc::now().to_rfc3339());\n metadata.remote_rev = pull_response.remote_rev;\n metadata.status = SyncStatus::InSync;\n drop(metadata);\n self.save_state().await?;\n self.save_metadata().await?;\n\n Ok(pull_response)\n }\n\n /// Add repository to local state\n #[allow(dead_code)]\n pub async fn add_repo(&self, name: String, id: String) -> Result<()> {\n let mut state = self.state.write().await;\n state.repos.insert(\n name.clone(),\n RepoState {\n id,\n name,\n local_path: None,\n synced_at: None,\n local_rev: 0,\n },\n );\n state.updated_at = chrono::Utc::now().to_rfc3339();\n drop(state);\n self.mark_pending_push().await?;\n self.save_state().await\n }\n\n /// Add pipeline to local state\n #[allow(dead_code)]\n pub async fn add_pipeline(&self, id: String, repo_id: String, name: String, definition: String) -> Result<()> {\n let mut state = self.state.write().await;\n state.pipelines.insert(\n id.clone(),\n PipelineState {\n id,\n repo_id,\n name,\n definition,\n synced_at: None,\n local_rev: 0,\n },\n );\n state.updated_at = chrono::Utc::now().to_rfc3339();\n drop(state);\n self.mark_pending_push().await?;\n self.save_state().await\n }\n}\n\n// API types for sync protocol\n\n#[derive(Debug, Serialize)]\npub struct PushPayload {\n pub local_rev: u64,\n pub repos: HashMap,\n pub pipelines: HashMap,\n pub timestamp: String,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct PushResponse {\n #[allow(dead_code)]\n pub success: bool,\n pub remote_rev: u64,\n #[allow(dead_code)]\n pub conflicts: Vec,\n}\n\n#[derive(Debug, Serialize)]\n#[allow(dead_code)]\npub struct PullRequest {\n pub local_rev: u64,\n pub last_sync: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct PullResponse {\n pub repos: HashMap,\n pub pipelines: HashMap,\n pub remote_rev: u64,\n #[allow(dead_code)]\n pub has_more: bool,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_sync_client_init() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n assert!(client.sync_dir().exists());\n }\n\n #[tokio::test]\n async fn test_sync_status_default() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n assert_eq!(client.status().await, SyncStatus::InSync);\n }\n\n #[tokio::test]\n async fn test_add_repo() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n client.add_repo(\"test-repo\".to_string(), \"repo-123\".to_string()).await.unwrap();\n let state = client.state.read().await;\n assert!(state.repos.contains_key(\"test-repo\"));\n }\n\n #[test]\n fn test_sync_metadata_default() {\n let metadata = SyncMetadata::default();\n assert_eq!(metadata.local_rev, 0);\n assert_eq!(metadata.remote_rev, 0);\n assert!(metadata.last_push.is_none());\n assert!(metadata.last_pull.is_none());\n assert_eq!(metadata.status, SyncStatus::InSync);\n }\n\n #[test]\n fn test_sync_status_variants() {\n assert_eq!(SyncStatus::InSync, SyncStatus::InSync);\n assert_eq!(SyncStatus::PendingPush, SyncStatus::PendingPush);\n assert_eq!(SyncStatus::PendingPull, SyncStatus::PendingPull);\n assert_eq!(SyncStatus::Conflict, SyncStatus::Conflict);\n }\n\n #[test]\n fn test_sync_status_serialization() {\n let status = SyncStatus::PendingPush;\n let json = serde_json::to_string(&status).unwrap();\n assert_eq!(json, \"\\\"pendingpush\\\"\");\n let parsed: SyncStatus = serde_json::from_str(&json).unwrap();\n assert_eq!(parsed, SyncStatus::PendingPush);\n }\n\n #[test]\n fn test_local_state_default() {\n let state = LocalState::default();\n assert!(state.repos.is_empty());\n assert!(state.pipelines.is_empty());\n }\n\n #[test]\n fn test_repo_state_creation() {\n let repo = RepoState {\n id: \"repo-1\".to_string(),\n name: \"test-repo\".to_string(),\n local_path: Some(PathBuf::from(\"/tmp/repo\")),\n synced_at: Some(\"2024-01-01T00:00:00Z\".to_string()),\n local_rev: 5,\n };\n assert_eq!(repo.name, \"test-repo\");\n assert_eq!(repo.local_rev, 5);\n }\n\n #[test]\n fn test_pipeline_state_creation() {\n let pipeline = PipelineState {\n id: \"pipe-1\".to_string(),\n repo_id: \"repo-1\".to_string(),\n name: \"build\".to_string(),\n definition: \"steps: [build]\".to_string(),\n synced_at: None,\n local_rev: 0,\n };\n assert_eq!(pipeline.name, \"build\");\n assert!(pipeline.synced_at.is_none());\n }\n\n #[test]\n fn test_push_payload_structure() {\n let payload = PushPayload {\n local_rev: 10,\n repos: HashMap::new(),\n pipelines: HashMap::new(),\n timestamp: \"2024-01-01T00:00:00Z\".to_string(),\n };\n assert_eq!(payload.local_rev, 10);\n }\n\n #[test]\n fn test_pull_request_structure() {\n let request = PullRequest {\n local_rev: 5,\n last_sync: Some(\"2024-01-01T00:00:00Z\".to_string()),\n };\n assert_eq!(request.local_rev, 5);\n assert!(request.last_sync.is_some());\n }\n\n #[test]\n fn test_pull_response_structure() {\n let response = PullResponse {\n repos: HashMap::new(),\n pipelines: HashMap::new(),\n remote_rev: 15,\n has_more: false,\n };\n assert_eq!(response.remote_rev, 15);\n assert!(!response.has_more);\n }\n\n #[tokio::test]\n async fn test_sync_client_new() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n assert_eq!(client.sync_dir(), temp_dir.path().join(\".gitforge\"));\n }\n\n #[tokio::test]\n async fn test_mark_pending_push() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n client.mark_pending_push().await.unwrap();\n assert_eq!(client.status().await, SyncStatus::PendingPush);\n }\n\n #[test]\n fn test_sync_metadata_serialization() {\n let metadata = SyncMetadata {\n last_push: Some(\"2024-01-01T00:00:00Z\".to_string()),\n last_pull: Some(\"2024-01-02T00:00:00Z\".to_string()),\n local_rev: 5,\n remote_rev: 10,\n status: SyncStatus::PendingPush,\n };\n let json = serde_json::to_string(&metadata).unwrap();\n assert!(json.contains(\"pendingpush\"));\n let parsed: SyncMetadata = serde_json::from_str(&json).unwrap();\n assert_eq!(parsed.local_rev, 5);\n }\n\n #[test]\n fn test_local_state_serialization() {\n let state = LocalState {\n repos: HashMap::new(),\n pipelines: HashMap::new(),\n updated_at: \"2024-01-01T00:00:00Z\".to_string(),\n };\n let json = serde_json::to_string(&state).unwrap();\n assert!(json.contains(\"2024-01-01\"));\n }\n\n #[test]\n fn test_repo_state_serialization() {\n let repo = RepoState {\n id: \"repo-1\".to_string(),\n name: \"test-repo\".to_string(),\n local_path: Some(PathBuf::from(\"/tmp/repo\")),\n synced_at: None,\n local_rev: 0,\n };\n let json = serde_json::to_string(&repo).unwrap();\n assert!(json.contains(\"test-repo\"));\n }\n\n #[test]\n fn test_pipeline_state_serialization() {\n let pipeline = PipelineState {\n id: \"pipe-1\".to_string(),\n repo_id: \"repo-1\".to_string(),\n name: \"build\".to_string(),\n definition: \"steps: [build]\".to_string(),\n synced_at: None,\n local_rev: 0,\n };\n let json = serde_json::to_string(&pipeline).unwrap();\n assert!(json.contains(\"build\"));\n }\n\n #[test]\n fn test_sync_status_all_variants() {\n let variants = vec![\n SyncStatus::InSync,\n SyncStatus::PendingPush,\n SyncStatus::PendingPull,\n SyncStatus::Conflict,\n ];\n assert_eq!(variants.len(), 4);\n }\n\n #[test]\n fn test_sync_status_partial_eq() {\n assert_eq!(SyncStatus::InSync, SyncStatus::InSync);\n assert_ne!(SyncStatus::InSync, SyncStatus::PendingPush);\n assert_ne!(SyncStatus::PendingPush, SyncStatus::PendingPull);\n }\n\n #[tokio::test]\n async fn test_add_pipeline() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n client.add_pipeline(\n \"pipe-1\".to_string(),\n \"repo-1\".to_string(),\n \"build\".to_string(),\n \"steps: [build]\".to_string(),\n ).await.unwrap();\n let state = client.state.read().await;\n assert!(state.pipelines.contains_key(\"pipe-1\"));\n }\n\n #[tokio::test]\n async fn test_sync_client_sync_dir() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n let sync_dir = client.sync_dir();\n assert_eq!(sync_dir, temp_dir.path().join(\".gitforge\"));\n }\n\n #[tokio::test]\n async fn test_add_repo_updates_timestamp() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n let before = client.state.read().await.updated_at.clone();\n client.add_repo(\"test-repo\".to_string(), \"repo-123\".to_string()).await.unwrap();\n let after = client.state.read().await.updated_at.clone();\n assert!(!after.is_empty());\n }\n\n #[tokio::test]\n async fn test_add_pipeline_updates_timestamp() {\n let temp_dir = tempfile::tempdir().unwrap();\n let client = SyncClient::with_real_client(temp_dir.path().to_path_buf());\n client.init().await.unwrap();\n client.add_pipeline(\n \"pipe-1\".to_string(),\n \"repo-1\".to_string(),\n \"build\".to_string(),\n \"steps: [build]\".to_string(),\n ).await.unwrap();\n let state = client.state.read().await;\n assert!(!state.updated_at.is_empty());\n }\n\n #[test]\n fn test_sync_metadata_with_all_fields() {\n let metadata = SyncMetadata {\n last_push: Some(\"2024-01-01T00:00:00Z\".to_string()),\n last_pull: Some(\"2024-01-02T00:00:00Z\".to_string()),\n local_rev: 100,\n remote_rev: 200,\n status: SyncStatus::Conflict,\n };\n assert_eq!(metadata.local_rev, 100);\n assert_eq!(metadata.remote_rev, 200);\n assert!(matches!(metadata.status, SyncStatus::Conflict));\n }\n\n #[test]\n fn test_push_response_structure() {\n let response = PushResponse {\n success: true,\n remote_rev: 42,\n conflicts: vec![],\n };\n assert!(response.success);\n assert_eq!(response.remote_rev, 42);\n assert!(response.conflicts.is_empty());\n }\n\n #[test]\n fn test_push_response_with_conflicts() {\n let response = PushResponse {\n success: false,\n remote_rev: 10,\n conflicts: vec![\"repo-1\".to_string(), \"repo-2\".to_string()],\n };\n assert!(!response.success);\n assert_eq!(response.conflicts.len(), 2);\n }\n\n #[test]\n fn test_local_state_with_data() {\n let mut state = LocalState::default();\n state.updated_at = \"2024-06-15T12:00:00Z\".to_string();\n state.repos.insert(\"test\".to_string(), RepoState {\n id: \"id-1\".to_string(),\n name: \"test\".to_string(),\n local_path: Some(PathBuf::from(\"/tmp/test\")),\n synced_at: None,\n local_rev: 1,\n });\n assert_eq!(state.repos.len(), 1);\n assert_eq!(state.updated_at, \"2024-06-15T12:00:00Z\");\n }\n\n #[tokio::test]\n async fn test_sync_client_push_with_mock() {\n let temp_dir = tempfile::tempdir().unwrap();\n let mock_client = MockHttpClient::new()\n .with_push_response(PushResponse {\n success: true,\n remote_rev: 42,\n conflicts: vec![],\n });\n let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client);\n client.init().await.unwrap();\n\n let result = client.push(\"http://localhost:8080\", \"test-token\").await;\n assert!(result.is_ok());\n let response = result.unwrap();\n assert_eq!(response.remote_rev, 42);\n assert!(response.conflicts.is_empty());\n }\n\n #[tokio::test]\n async fn test_sync_client_push_with_mock_error() {\n let temp_dir = tempfile::tempdir().unwrap();\n let mock_client = MockHttpClient::new()\n .with_push_error(anyhow::anyhow!(\"server error\"));\n let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client);\n client.init().await.unwrap();\n\n let result = client.push(\"http://localhost:8080\", \"test-token\").await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_sync_client_pull_with_mock() {\n let temp_dir = tempfile::tempdir().unwrap();\n let mock_client = MockHttpClient::new()\n .with_pull_response(PullResponse {\n repos: HashMap::new(),\n pipelines: HashMap::new(),\n remote_rev: 100,\n has_more: false,\n });\n let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client);\n client.init().await.unwrap();\n\n let result = client.pull(\"http://localhost:8080\", \"test-token\").await;\n assert!(result.is_ok());\n let response = result.unwrap();\n assert_eq!(response.remote_rev, 100);\n assert!(!response.has_more);\n }\n\n #[tokio::test]\n async fn test_sync_client_pull_with_mock_error() {\n let temp_dir = tempfile::tempdir().unwrap();\n let mock_client = MockHttpClient::new()\n .with_push_error(anyhow::anyhow!(\"server error\"));\n let client = SyncClient::new(temp_dir.path().to_path_buf(), mock_client);\n client.init().await.unwrap();\n\n let result = client.pull(\"http://localhost:8080\", \"test-token\").await;\n assert!(result.is_err());\n }\n\n #[test]\n fn test_mock_http_client_push_response() {\n let mock = MockHttpClient::new()\n .with_push_response(PushResponse {\n success: true,\n remote_rev: 5,\n conflicts: vec![\"a\".to_string()],\n });\n assert!(mock.push_response.is_some());\n assert!(mock.pull_response.is_none());\n }\n\n #[test]\n fn test_mock_http_client_pull_response() {\n let mock = MockHttpClient::new()\n .with_pull_response(PullResponse {\n repos: HashMap::new(),\n pipelines: HashMap::new(),\n remote_rev: 10,\n has_more: true,\n });\n assert!(mock.pull_response.is_some());\n assert!(mock.push_response.is_none());\n }\n}\n","traces":[{"line":37,"address":[9664432],"length":1,"stats":{"Line":3}},{"line":39,"address":[9664433],"length":1,"stats":{"Line":2}},{"line":45,"address":[9663040],"length":1,"stats":{"Line":0}},{"line":46,"address":[9663041],"length":1,"stats":{"Line":0}},{"line":52,"address":[9801017,9800159,9800282,9800450,9802487,9800325,9800055,9800838,9800112],"length":1,"stats":{"Line":0}},{"line":58,"address":[],"length":0,"stats":{"Line":0}},{"line":59,"address":[],"length":0,"stats":{"Line":0}},{"line":60,"address":[],"length":0,"stats":{"Line":0}},{"line":61,"address":[9800547,9800496,9800995,9800740,9800578],"length":1,"stats":{"Line":0}},{"line":62,"address":[],"length":0,"stats":{"Line":0}},{"line":64,"address":[9800312,9801057,9801244,9800928,9800979],"length":1,"stats":{"Line":0}},{"line":67,"address":[],"length":0,"stats":{"Line":0}},{"line":68,"address":[],"length":0,"stats":{"Line":0}},{"line":71,"address":[9702578],"length":1,"stats":{"Line":0}},{"line":72,"address":[],"length":0,"stats":{"Line":0}},{"line":75,"address":[9797592,9797471,9797738,9798248,9797536,9797695,9798389,9797863,9799937],"length":1,"stats":{"Line":0}},{"line":80,"address":[],"length":0,"stats":{"Line":0}},{"line":81,"address":[],"length":0,"stats":{"Line":0}},{"line":82,"address":[9797833,9797952],"length":1,"stats":{"Line":0}},{"line":83,"address":[],"length":0,"stats":{"Line":0}},{"line":85,"address":[9798351,9798300,9797725,9798429,9798607],"length":1,"stats":{"Line":0}},{"line":88,"address":[],"length":0,"stats":{"Line":0}},{"line":89,"address":[9798976,9799063],"length":1,"stats":{"Line":0}},{"line":92,"address":[9701570],"length":1,"stats":{"Line":0}},{"line":93,"address":[9799873],"length":1,"stats":{"Line":0}},{"line":106,"address":[9664336],"length":1,"stats":{"Line":1}},{"line":113,"address":[9664112,9664315],"length":1,"stats":{"Line":1}},{"line":114,"address":[9664267,9664142],"length":1,"stats":{"Line":2}},{"line":115,"address":[9664292],"length":1,"stats":{"Line":1}},{"line":118,"address":[9664090,9663712],"length":1,"stats":{"Line":1}},{"line":119,"address":[9664038,9663742],"length":1,"stats":{"Line":2}},{"line":120,"address":[9664067],"length":1,"stats":{"Line":2}},{"line":123,"address":[9663690,9663488],"length":1,"stats":{"Line":1}},{"line":124,"address":[9663642,9663523],"length":1,"stats":{"Line":2}},{"line":125,"address":[9663667],"length":1,"stats":{"Line":1}},{"line":130,"address":[9663008],"length":1,"stats":{"Line":0}},{"line":131,"address":[9663016],"length":1,"stats":{"Line":0}},{"line":137,"address":[9796779,9796438,9796526,9797385,9797210,9796659,9796354,9796400,9797215],"length":1,"stats":{"Line":5}},{"line":143,"address":[],"length":0,"stats":{"Line":2}},{"line":144,"address":[],"length":0,"stats":{"Line":1}},{"line":145,"address":[],"length":0,"stats":{"Line":1}},{"line":146,"address":[],"length":0,"stats":{"Line":2}},{"line":148,"address":[],"length":0,"stats":{"Line":2}},{"line":149,"address":[],"length":0,"stats":{"Line":0}},{"line":153,"address":[9796123,9796118,9795318,9795280,9795526,9795245,9795392,9796283,9795637],"length":1,"stats":{"Line":5}},{"line":158,"address":[],"length":0,"stats":{"Line":2}},{"line":159,"address":[],"length":0,"stats":{"Line":1}},{"line":160,"address":[9795800],"length":1,"stats":{"Line":1}},{"line":161,"address":[9795924,9795845],"length":1,"stats":{"Line":2}},{"line":163,"address":[],"length":0,"stats":{"Line":0}},{"line":164,"address":[],"length":0,"stats":{"Line":2}},{"line":237,"address":[9813963,9813981,9813616],"length":1,"stats":{"Line":1}},{"line":240,"address":[],"length":0,"stats":{"Line":2}},{"line":241,"address":[],"length":0,"stats":{"Line":2}},{"line":249,"address":[9663056,9663455],"length":1,"stats":{"Line":4}},{"line":251,"address":[9663083],"length":1,"stats":{"Line":3}},{"line":252,"address":[9663184,9663139],"length":1,"stats":{"Line":4}},{"line":253,"address":[9663219,9663260],"length":1,"stats":{"Line":4}},{"line":254,"address":[9663290],"length":1,"stats":{"Line":1}},{"line":261,"address":[],"length":0,"stats":{"Line":18}},{"line":262,"address":[],"length":0,"stats":{"Line":7}},{"line":263,"address":[9820314,9815879,9814444,9818876,9818801,9814369],"length":1,"stats":{"Line":6}},{"line":265,"address":[9819022,9814590],"length":1,"stats":{"Line":4}},{"line":266,"address":[9819208,9819109,9814776,9814677],"length":1,"stats":{"Line":7}},{"line":269,"address":[9814812,9819335,9819244,9814903],"length":1,"stats":{"Line":7}},{"line":270,"address":[],"length":0,"stats":{"Line":0}},{"line":271,"address":[],"length":0,"stats":{"Line":0}},{"line":272,"address":[9696145,9708522,9708570,9696278,9696186,9708614,9696234,9708481],"length":1,"stats":{"Line":0}},{"line":276,"address":[9819364,9816507,9814932,9820942],"length":1,"stats":{"Line":8}},{"line":277,"address":[],"length":0,"stats":{"Line":0}},{"line":278,"address":[],"length":0,"stats":{"Line":0}},{"line":279,"address":[],"length":0,"stats":{"Line":0}},{"line":282,"address":[9816536,9820971],"length":1,"stats":{"Line":2}},{"line":286,"address":[9846768,9846848],"length":1,"stats":{"Line":4}},{"line":287,"address":[],"length":0,"stats":{"Line":5}},{"line":291,"address":[],"length":0,"stats":{"Line":12}},{"line":292,"address":[],"length":0,"stats":{"Line":3}},{"line":293,"address":[9811016,9811374,9809304,9809241,9809659,9810953],"length":1,"stats":{"Line":6}},{"line":294,"address":[9809482,9811194,9811119,9809407],"length":1,"stats":{"Line":7}},{"line":295,"address":[9713689,9698761],"length":1,"stats":{"Line":6}},{"line":296,"address":[],"length":0,"stats":{"Line":6}},{"line":297,"address":[],"length":0,"stats":{"Line":7}},{"line":298,"address":[],"length":0,"stats":{"Line":4}},{"line":302,"address":[9804458,9804506,9802746,9805005,9802794,9802600,9802568,9802624,9802592,9804336,9804364,9804239,9805951,9802652,9802560,9803293],"length":1,"stats":{"Line":12}},{"line":303,"address":[9802735,9804447],"length":1,"stats":{"Line":2}},{"line":304,"address":[],"length":0,"stats":{"Line":6}},{"line":305,"address":[],"length":0,"stats":{"Line":6}},{"line":306,"address":[],"length":0,"stats":{"Line":6}},{"line":307,"address":[],"length":0,"stats":{"Line":6}},{"line":308,"address":[9805674,9804186,9803962,9805898,9803814,9805526],"length":1,"stats":{"Line":6}},{"line":309,"address":[],"length":0,"stats":{"Line":2}},{"line":313,"address":[],"length":0,"stats":{"Line":4}},{"line":314,"address":[9844002,9844265,9843919,9843809,9843855],"length":1,"stats":{"Line":3}},{"line":318,"address":[],"length":0,"stats":{"Line":4}},{"line":319,"address":[9715647],"length":1,"stats":{"Line":2}},{"line":320,"address":[],"length":0,"stats":{"Line":2}},{"line":321,"address":[],"length":0,"stats":{"Line":1}},{"line":322,"address":[],"length":0,"stats":{"Line":2}},{"line":323,"address":[9812611,9813343,9813236,9813169],"length":1,"stats":{"Line":3}},{"line":327,"address":[],"length":0,"stats":{"Line":7}},{"line":328,"address":[],"length":0,"stats":{"Line":0}},{"line":329,"address":[],"length":0,"stats":{"Line":6}},{"line":331,"address":[],"length":0,"stats":{"Line":0}},{"line":332,"address":[9709543,9697207],"length":1,"stats":{"Line":3}},{"line":337,"address":[9835710,9840491],"length":1,"stats":{"Line":2}},{"line":338,"address":[9835725,9840506],"length":1,"stats":{"Line":2}},{"line":339,"address":[],"length":0,"stats":{"Line":2}},{"line":340,"address":[9835904,9840610,9840685,9835829],"length":1,"stats":{"Line":4}},{"line":344,"address":[],"length":0,"stats":{"Line":4}},{"line":345,"address":[],"length":0,"stats":{"Line":8}},{"line":346,"address":[9841135,9836244,9836354,9841025],"length":1,"stats":{"Line":4}},{"line":347,"address":[],"length":0,"stats":{"Line":3}},{"line":351,"address":[9841789,9841885,9837008,9837107,9839272,9834488,9841991,9837213],"length":1,"stats":{"Line":2}},{"line":352,"address":[9837660,9837598,9837473,9842232,9842376,9842251,9842859,9838081,9837454,9842438],"length":1,"stats":{"Line":1}},{"line":353,"address":[],"length":0,"stats":{"Line":2}},{"line":354,"address":[],"length":0,"stats":{"Line":1}},{"line":355,"address":[],"length":0,"stats":{"Line":1}},{"line":356,"address":[9834509,9837976,9839293,9842899,9842754,9838121],"length":1,"stats":{"Line":1}},{"line":358,"address":[],"length":0,"stats":{"Line":1}},{"line":362,"address":[],"length":0,"stats":{"Line":4}},{"line":364,"address":[9823312,9823519,9828768,9828975],"length":1,"stats":{"Line":2}},{"line":365,"address":[9829106,9823650,9824829,9829332,9829847,9824228,9829897,9829684,9824441,9823876,9824391,9829558,9830285,9824102],"length":1,"stats":{"Line":9}},{"line":366,"address":[9829246,9823669,9823790,9829125],"length":1,"stats":{"Line":2}},{"line":367,"address":[9823372,9824214,9829410,9823898,9823954,9829285,9828828,9829670,9829354,9823829],"length":1,"stats":{"Line":3}},{"line":372,"address":[],"length":0,"stats":{"Line":2}},{"line":373,"address":[],"length":0,"stats":{"Line":2}},{"line":374,"address":[],"length":0,"stats":{"Line":0}},{"line":376,"address":[9830829,9825373],"length":1,"stats":{"Line":1}},{"line":377,"address":[],"length":0,"stats":{"Line":0}},{"line":379,"address":[9831218,9825586,9831042,9826059,9831512,9831150,9825762,9831061,9825605,9825694],"length":1,"stats":{"Line":1}},{"line":383,"address":[9709117,9696781],"length":1,"stats":{"Line":2}},{"line":384,"address":[],"length":0,"stats":{"Line":1}},{"line":385,"address":[9832690,9827143,9827237,9832596],"length":1,"stats":{"Line":2}},{"line":386,"address":[],"length":0,"stats":{"Line":1}},{"line":387,"address":[9832730,9827277],"length":1,"stats":{"Line":1}},{"line":388,"address":[9832935,9827889,9828891,9832790,9833342,9823435,9827482,9827337],"length":1,"stats":{"Line":1}},{"line":389,"address":[],"length":0,"stats":{"Line":1}},{"line":391,"address":[9828233,9833686],"length":1,"stats":{"Line":1}},{"line":396,"address":[9844344,9844428,9844612,9844767,9844569,9844336,9844400,9845860,9846719,9844633],"length":1,"stats":{"Line":4}},{"line":397,"address":[],"length":0,"stats":{"Line":2}},{"line":398,"address":[9845093,9845025,9845384],"length":1,"stats":{"Line":3}},{"line":399,"address":[9845102],"length":1,"stats":{"Line":1}},{"line":400,"address":[],"length":0,"stats":{"Line":1}},{"line":401,"address":[],"length":0,"stats":{"Line":1}},{"line":402,"address":[],"length":0,"stats":{"Line":1}},{"line":403,"address":[9845204],"length":1,"stats":{"Line":1}},{"line":404,"address":[9845212],"length":1,"stats":{"Line":1}},{"line":405,"address":[],"length":0,"stats":{"Line":0}},{"line":408,"address":[9845406,9845425,9845574,9845512,9845828],"length":1,"stats":{"Line":1}},{"line":409,"address":[],"length":0,"stats":{"Line":1}},{"line":410,"address":[],"length":0,"stats":{"Line":1}},{"line":411,"address":[9846231,9846424,9844641,9846163],"length":1,"stats":{"Line":2}},{"line":416,"address":[],"length":0,"stats":{"Line":4}},{"line":417,"address":[],"length":0,"stats":{"Line":2}},{"line":418,"address":[],"length":0,"stats":{"Line":3}},{"line":419,"address":[],"length":0,"stats":{"Line":1}},{"line":420,"address":[9807175],"length":1,"stats":{"Line":1}},{"line":421,"address":[],"length":0,"stats":{"Line":1}},{"line":422,"address":[9807046],"length":1,"stats":{"Line":1}},{"line":423,"address":[],"length":0,"stats":{"Line":1}},{"line":424,"address":[9807120],"length":1,"stats":{"Line":1}},{"line":425,"address":[],"length":0,"stats":{"Line":1}},{"line":426,"address":[],"length":0,"stats":{"Line":0}},{"line":429,"address":[],"length":0,"stats":{"Line":1}},{"line":430,"address":[],"length":0,"stats":{"Line":1}},{"line":431,"address":[],"length":0,"stats":{"Line":1}},{"line":432,"address":[],"length":0,"stats":{"Line":2}}],"covered":128,"coverable":167},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-cli","tests","cli.rs"],"content":"//! CLI integration tests\n//!\n//! Tests for CLI argument parsing and command handling.\n\nuse clap::Parser;\n\n#[derive(Parser, Debug)]\n#[command(name = \"gitforge\")]\n#[command(version = \"0.1.0\")]\nstruct TestCli {\n #[arg(short, long)]\n verbose: bool,\n #[arg(short, long)]\n server: Option,\n #[arg(short, long)]\n token: Option,\n #[command(subcommand)]\n command: TestCommands,\n}\n\n#[derive(clap::Subcommand, Debug)]\nenum TestCommands {\n /// Authentication commands\n Auth {\n #[arg(long)]\n login: Option,\n #[arg(long)]\n logout: bool,\n #[arg(long)]\n status: bool,\n },\n /// Repository commands\n Repo {\n #[arg(long)]\n list: bool,\n #[arg(long)]\n create: Option,\n #[arg(long)]\n info: Option,\n #[arg(long)]\n delete: Option,\n },\n /// Pipeline commands\n Pipeline {\n #[arg(long)]\n list: bool,\n #[arg(long)]\n show: Option,\n #[arg(long)]\n run: Option,\n #[arg(long)]\n watch: Option,\n },\n /// Runner commands\n Runner {\n #[arg(long)]\n list: bool,\n #[arg(long)]\n info: Option,\n #[arg(long)]\n register: Option,\n #[arg(long)]\n capacity: Option,\n },\n /// Sync commands\n Sync {\n #[arg(long)]\n status: bool,\n #[arg(long)]\n push: bool,\n #[arg(long)]\n pull: bool,\n #[arg(long)]\n init: Option,\n },\n}\n\n#[test]\nfn test_cli_auth_login() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"--verbose\", \"auth\", \"--login\", \"testuser\"]).unwrap();\n assert!(cli.verbose);\n match cli.command {\n TestCommands::Auth { login, logout: _, status: _ } => {\n assert_eq!(login, Some(\"testuser\".to_string()));\n }\n _ => panic!(\"Expected Auth command\"),\n }\n}\n\n#[test]\nfn test_cli_auth_logout() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"auth\", \"--logout\"]).unwrap();\n match cli.command {\n TestCommands::Auth { login: _, logout, status: _ } => {\n assert!(logout);\n }\n _ => panic!(\"Expected Auth command\"),\n }\n}\n\n#[test]\nfn test_cli_auth_status() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"auth\", \"--status\"]).unwrap();\n match cli.command {\n TestCommands::Auth { login: _, logout: _, status } => {\n assert!(status);\n }\n _ => panic!(\"Expected Auth command\"),\n }\n}\n\n#[test]\nfn test_cli_repo_list() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"repo\", \"--list\"]).unwrap();\n match cli.command {\n TestCommands::Repo { list, create: _, info: _, delete: _ } => {\n assert!(list);\n }\n _ => panic!(\"Expected Repo command\"),\n }\n}\n\n#[test]\nfn test_cli_repo_create() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"repo\", \"--create\", \"my-repo\"]).unwrap();\n match cli.command {\n TestCommands::Repo { list: _, create, info: _, delete: _ } => {\n assert_eq!(create, Some(\"my-repo\".to_string()));\n }\n _ => panic!(\"Expected Repo command\"),\n }\n}\n\n#[test]\nfn test_cli_repo_info() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"repo\", \"--info\", \"test-repo\"]).unwrap();\n match cli.command {\n TestCommands::Repo { list: _, create: _, info, delete: _ } => {\n assert_eq!(info, Some(\"test-repo\".to_string()));\n }\n _ => panic!(\"Expected Repo command\"),\n }\n}\n\n#[test]\nfn test_cli_repo_delete() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"repo\", \"--delete\", \"old-repo\"]).unwrap();\n match cli.command {\n TestCommands::Repo { list: _, create: _, info: _, delete } => {\n assert_eq!(delete, Some(\"old-repo\".to_string()));\n }\n _ => panic!(\"Expected Repo command\"),\n }\n}\n\n#[test]\nfn test_cli_pipeline_list() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"pipeline\", \"--list\"]).unwrap();\n match cli.command {\n TestCommands::Pipeline { list, show: _, run: _, watch: _ } => {\n assert!(list);\n }\n _ => panic!(\"Expected Pipeline command\"),\n }\n}\n\n#[test]\nfn test_cli_pipeline_show() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"pipeline\", \"--show\", \"abc123\"]).unwrap();\n match cli.command {\n TestCommands::Pipeline { list: _, show, run: _, watch: _ } => {\n assert_eq!(show, Some(\"abc123\".to_string()));\n }\n _ => panic!(\"Expected Pipeline command\"),\n }\n}\n\n#[test]\nfn test_cli_pipeline_run() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"pipeline\", \"--run\", \"pipeline-xyz\"]).unwrap();\n match cli.command {\n TestCommands::Pipeline { list: _, show: _, run, watch: _ } => {\n assert_eq!(run, Some(\"pipeline-xyz\".to_string()));\n }\n _ => panic!(\"Expected Pipeline command\"),\n }\n}\n\n#[test]\nfn test_cli_runner_list() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"runner\", \"--list\"]).unwrap();\n match cli.command {\n TestCommands::Runner { list, info: _, register: _, capacity: _ } => {\n assert!(list);\n }\n _ => panic!(\"Expected Runner command\"),\n }\n}\n\n#[test]\nfn test_cli_runner_register() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"runner\", \"--register\", \"my-runner\"]).unwrap();\n match cli.command {\n TestCommands::Runner { list: _, info: _, register, capacity } => {\n assert_eq!(register, Some(\"my-runner\".to_string()));\n assert_eq!(capacity, None);\n }\n _ => panic!(\"Expected Runner command\"),\n }\n}\n\n#[test]\nfn test_cli_runner_register_with_capacity() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"runner\", \"--register\", \"big-runner\", \"--capacity\", \"8\"]).unwrap();\n match cli.command {\n TestCommands::Runner { list: _, info: _, register, capacity } => {\n assert_eq!(register, Some(\"big-runner\".to_string()));\n assert_eq!(capacity, Some(8));\n }\n _ => panic!(\"Expected Runner command\"),\n }\n}\n\n#[test]\nfn test_cli_sync_status() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"sync\", \"--status\"]).unwrap();\n match cli.command {\n TestCommands::Sync { status, push: _, pull: _, init: _ } => {\n assert!(status);\n }\n _ => panic!(\"Expected Sync command\"),\n }\n}\n\n#[test]\nfn test_cli_sync_init() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"sync\", \"--init\", \"/path/to/dir\"]).unwrap();\n match cli.command {\n TestCommands::Sync { status: _, push: _, pull: _, init } => {\n assert_eq!(init, Some(\"/path/to/dir\".to_string()));\n }\n _ => panic!(\"Expected Sync command\"),\n }\n}\n\n#[test]\nfn test_cli_verbose_flag() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"--verbose\", \"auth\", \"--status\"]).unwrap();\n assert!(cli.verbose);\n}\n\n#[test]\nfn test_cli_server_option() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"--server\", \"http://localhost:9090\", \"auth\", \"--status\"]).unwrap();\n assert_eq!(cli.server, Some(\"http://localhost:9090\".to_string()));\n}\n\n#[test]\nfn test_cli_token_option() {\n let cli = TestCli::try_parse_from(&[\"gitforge\", \"--token\", \"secret-token\", \"auth\", \"--status\"]).unwrap();\n assert_eq!(cli.token, Some(\"secret-token\".to_string()));\n}\n\n#[test]\nfn test_cli_unknown_subcommand_fails() {\n let result = TestCli::try_parse_from(&[\"gitforge\", \"unknown\", \"--something\"]);\n assert!(result.is_err());\n}\n","traces":[{"line":1,"address":[1034781,1034768],"length":1,"stats":{"Line":2}},{"line":7,"address":[1040161,1042308,1044548,1038488,1034800,1043227,1040924,1042657,1037476,1043054,1044074,1038645,1042832,1044302,1043968,1039033,1037810,1039456,1042127,1042528,1041487,1040772,1041019,1042753,1042866,1040847,1041176,1041564,1036304,1042608,1044458,1038062,1037316,1042609,1042976,1041816,1042736,1044642,1038393,1044651,1039328,1043904,1043291,1043782,1036417,1037104,1041412,1044375,1038316,1043450,1042640,1041659,1043995,1034872,1039253,1044195,1037733,1038899,1042052,1040533,1043744,1037905,1040373],"length":1,"stats":{"Line":180}},{"line":11,"address":[1037256,1040422,1040130,1040313,1040820,1037073,1040366,1037309,1037706,1043950,1044167,1037365,1040748],"length":1,"stats":{"Line":80}},{"line":12,"address":[1042506,1039231,1043014,1034843,1040321,1037145,1044037,1037264,1040202],"length":1,"stats":{"Line":47}},{"line":13,"address":[1044334,1041065,1037779,1044276,1037898,1044260,1041460,1037951,1038289,1040893,1041012,1041388,1043977],"length":1,"stats":{"Line":64}},{"line":14,"address":[1037851,1043156,1040965,1044226],"length":1,"stats":{"Line":31}},{"line":15,"address":[1044576,1042028,1044522,1038362,1041705,1038872,1041652,1038534,1041533,1042100,1038481,1044177,1044507],"length":1,"stats":{"Line":64}},{"line":16,"address":[1034853,1041605,1043193,1044474,1038434],"length":1,"stats":{"Line":17}},{"line":17,"address":[1043348,1043284,1038980,1042208],"length":1,"stats":{"Line":17}},{"line":18,"address":[1044387,1034860,1044607],"length":1,"stats":{"Line":0}},{"line":21,"address":[1092589,1094174,1060914,1070227,1088074,1054299,1065390,1084345,1073846,1075664,1083522,1056635,1084178,1049111,1093972,1062023,1057666,1060080,1087748,1090247,1074906,1073392,1075287,1050464,1085643,1068049,1075587,1060465,1093292,1072297,1050932,1035395,1082800,1046175,1050855,1061311,1062363,1062627,1076385,1035099,1082657,1085787,1035869,1081591,1054551,1069930,1088711,1059194,1067668,1087178,1072671,1087911,1061216,1073220,1078906,1084481,1072045,1072140,1080650,1086998,1048051,1068975,1079844,1082591,1054394,1059791,1065092,1049901,1044864,1035188,1057355,1078525,1056463,1047433,1080822,1089781,1092441,1092674,1063023,1072513,1072594,1093125,1084431,1061468,1056792,1093966,1089058,1060754,1063285,1065550,1067889,1061957,1085729,1082969,1052859,1073297,1076542,1093412,1035631,1066308,1073139,1059868,1081657,1047654,1088840,1044917,1076758,1080979,1079113,1068349,1090253,1068894,1065011,1047356,1053173,1065169,1076290,1059406,1077384,1065850,1080208,1090783,1093637,1062891,1073549,1093517,1080368,1063232,1069052,1071968,1093742,1076839,1091395,1057103,1075916,1061690,1089932,1056540,1065927,1088629,1092362,1068678,1075759,1081258,1094095,1063089,1069678,1060542,1047814,1087422,1053831,1079188,1070146,1052478,1057198,1053096,1079637,1088957,1087120,1052699,1071508,1069304,1051027,1054222,1069147,1073765,1091020,1044688,1091247,1090912,1092214,1092743,1057589,1071668,1076213,1080575,1090376,1064543,1089670,1035056,1056066,1086427,1055845,1060240,1036107,1092763,1091474,1092668,1093862,1046738,1079477,1080727,1079996,1090014,1057918,1050292,1066608,1086960,1049806,1053988,1053268,1064795,1085476,1090165,1088478,1056226,1058152,1059566,1075506,1072923,1051418,1086268,1049332,1086569,1091683,1082195,1084827,1084994,1083440,1087585,1061139,1091168,1076916,1079919,1091480,1050058,1069773,1072766,1089167,1064638,1086109,1079265,1068521,1077011,1081183,1062089,1069601,1077168,1068268,1082459,1092109,1053736,1077465,1081931,1085080,1071287,1046427,1049729,1082834,1046959,1088951,1091989,1071887,1068426,1054785,1057761,1076132,1083725,1046661,1078746,1044701,1083643,1075127,1066527,1091603,1085130,1090459,1053425,1053659,1065769,1051184,1089541,1049492,1082944,1081525,1057026,1066148,1046270,1050621,1069520,1050369,1047119],"length":1,"stats":{"Line":460}},{"line":25,"address":[1088586,1088656,1046634,1088441,1064984,1046316,1064684,1088567,1046263,1046144,1064631,1064512,1065065],"length":1,"stats":{"Line":64}},{"line":26,"address":[1046216,1035146,1064584,1083360,1087332,1088525],"length":1,"stats":{"Line":20}},{"line":27,"address":[1065823,1088939,1046707,1065330,1046899,1047329,1047008,1065439,1065138,1088484,1046952,1065383,1065742],"length":1,"stats":{"Line":80}},{"line":28,"address":[1018225,1018001,1018000,1018224],"length":1,"stats":{"Line":36}},{"line":29,"address":[1066500,1047402,1066088,1047647,1065896,1066581,1048024,1066141,1047594,1047703,1066197,1088717,1089154],"length":1,"stats":{"Line":80}},{"line":30,"address":[1018032,1018033,1018096,1018097],"length":1,"stats":{"Line":36}},{"line":34,"address":[1068241,1067938,1067637,1089769,1067829,1049381,1049325,1067882,1049080,1049702,1068322,1089504,1049272],"length":1,"stats":{"Line":80}},{"line":35,"address":[1089588,1062869,1087464,1084102,1067709,1049152,1067837,1082437,1035338,1049280],"length":1,"stats":{"Line":36}},{"line":36,"address":[1068948,1049894,1089547,1089889,1050265,1068514,1089959,1068567,1089870,1068867,1049947,1068395,1049775],"length":1,"stats":{"Line":64}},{"line":37,"address":[1035353,1068467,1087495,1089828,1049847,1084261],"length":1,"stats":{"Line":20}},{"line":38,"address":[1050457,1069193,1089787,1050828,1069140,1069493,1069574,1090103,1090192,1050338,1050510,1069021,1090122],"length":1,"stats":{"Line":32}},{"line":39,"address":[1069093,1087526,1090061,1084303,1050410,1035368],"length":1,"stats":{"Line":19}},{"line":40,"address":[1051391,1090020,1051020,1051073,1069766,1090333,1070200,1090403,1050901,1069647,1069819,1070119,1090314],"length":1,"stats":{"Line":32}},{"line":41,"address":[1090272,1050973,1069719,1087557,1035383,1084389],"length":1,"stats":{"Line":5}},{"line":45,"address":[1091008,1071557,1052447,1052748,1071860,1053069,1071448,1071501,1090746,1071256,1052639,1052692,1071941],"length":1,"stats":{"Line":33}},{"line":46,"address":[1071456,1052647,1090830,1052519,1035574,1071328,1082173,1084751,1062605,1087627],"length":1,"stats":{"Line":33}},{"line":47,"address":[1053142,1091195,1091106,1072133,1090789,1053314,1072486,1053632,1072186,1072567,1072014,1091125,1053261],"length":1,"stats":{"Line":32}},{"line":48,"address":[1072086,1053214,1084910,1035589,1087658,1091064],"length":1,"stats":{"Line":4}},{"line":49,"address":[1053705,1054195,1073193,1091333,1072812,1053877,1053824,1073112,1072640,1072759,1091352,1091422,1091026],"length":1,"stats":{"Line":32}},{"line":50,"address":[1084952,1072712,1035604,1053777,1087689,1091291],"length":1,"stats":{"Line":18}},{"line":51,"address":[1073438,1091253,1054440,1073819,1091541,1091630,1073266,1073738,1073385,1054268,1054758,1091560,1054387],"length":1,"stats":{"Line":32}},{"line":52,"address":[1073338,1091499,1035619,1054340,1085038,1087720],"length":1,"stats":{"Line":4}},{"line":56,"address":[1075120,1056436,1075176,1056059,1075479,1092205,1056006,1074875,1075067,1075560,1056115,1091958,1055814],"length":1,"stats":{"Line":33}},{"line":57,"address":[1018193,1017808,1017809,1018192],"length":1,"stats":{"Line":33}},{"line":58,"address":[1092300,1092319,1075633,1092389,1075752,1091995,1056628,1056681,1075805,1076105,1056509,1056999,1076186],"length":1,"stats":{"Line":32}},{"line":59,"address":[1092258,1085559,1075705,1056581,1035827,1087821],"length":1,"stats":{"Line":4}},{"line":60,"address":[1076731,1092546,1092616,1076812,1092220,1057072,1092527,1057191,1057244,1076259,1076378,1057562,1076431],"length":1,"stats":{"Line":32}},{"line":61,"address":[1085601,1035842,1076331,1057144,1087852,1092485],"length":1,"stats":{"Line":18}},{"line":62,"address":[1076885,1057807,1058125,1077004,1092447,1057635,1077357,1092756,1077438,1077057,1057754],"length":1,"stats":{"Line":32}},{"line":63,"address":[1085687,1057707,1092693,1087883,1076957,1035857],"length":1,"stats":{"Line":4}},{"line":67,"address":[1059346,1059764,1078739,1059163,1079089,1078494,1059455,1093261,1093508,1078795,1078686,1059399,1079161],"length":1,"stats":{"Line":33}},{"line":68,"address":[1018256,1017873,1018257,1017872],"length":1,"stats":{"Line":31}},{"line":69,"address":[1059837,1079526,1079417,1060129,1060020,1079470,1060438,1079820,1093733,1079234,1060073,1093298,1079892],"length":1,"stats":{"Line":47}},{"line":70,"address":[1017841,1018289,1018288,1017840],"length":1,"stats":{"Line":3}},{"line":71,"address":[1093957,1060803,1060747,1061112,1060511,1080551,1093523,1080201,1060694,1080257,1080623,1080148,1079965],"length":1,"stats":{"Line":33}},{"line":72,"address":[1018160,1017936,1017937,1018161],"length":1,"stats":{"Line":31}},{"line":73,"address":[1081159,1093748,1094033,1094052,1094122,1080696,1061357,1081231,1080868,1061185,1061304,1080815,1061663],"length":1,"stats":{"Line":32}},{"line":74,"address":[1093991,1061257,1036095,1088046,1086510,1080768],"length":1,"stats":{"Line":2}},{"line":79,"address":[1016944,1016962],"length":1,"stats":{"Line":3}},{"line":80,"address":[1026535],"length":1,"stats":{"Line":1}},{"line":81,"address":[1026596],"length":1,"stats":{"Line":1}},{"line":82,"address":[1026636],"length":1,"stats":{"Line":1}},{"line":83,"address":[1026692],"length":1,"stats":{"Line":1}},{"line":84,"address":[1026726,1026829],"length":1,"stats":{"Line":2}},{"line":85,"address":[1027022,1026785],"length":1,"stats":{"Line":1}},{"line":86,"address":[1026753,1027230],"length":1,"stats":{"Line":0}},{"line":88,"address":[1027208,1027111,1027041,1026649,1027057,1027251],"length":1,"stats":{"Line":2}},{"line":91,"address":[1027552,1027558,1027328],"length":1,"stats":{"Line":3}},{"line":92,"address":[1027335],"length":1,"stats":{"Line":1}},{"line":93,"address":[1027380],"length":1,"stats":{"Line":1}},{"line":94,"address":[1027391],"length":1,"stats":{"Line":1}},{"line":95,"address":[1027441,1027407],"length":1,"stats":{"Line":1}},{"line":97,"address":[1027534,1027413],"length":1,"stats":{"Line":0}},{"line":99,"address":[1017029],"length":1,"stats":{"Line":2}},{"line":102,"address":[1017040,1017058],"length":1,"stats":{"Line":3}},{"line":103,"address":[1027591],"length":1,"stats":{"Line":1}},{"line":104,"address":[1027636],"length":1,"stats":{"Line":1}},{"line":105,"address":[1027647],"length":1,"stats":{"Line":1}},{"line":106,"address":[1027663,1027697],"length":1,"stats":{"Line":1}},{"line":108,"address":[1027790,1027669],"length":1,"stats":{"Line":0}},{"line":110,"address":[1027727],"length":1,"stats":{"Line":2}},{"line":113,"address":[1016848,1016866],"length":1,"stats":{"Line":3}},{"line":114,"address":[1025591],"length":1,"stats":{"Line":1}},{"line":115,"address":[1025636],"length":1,"stats":{"Line":1}},{"line":116,"address":[1025647],"length":1,"stats":{"Line":1}},{"line":117,"address":[1025697,1025663],"length":1,"stats":{"Line":1}},{"line":119,"address":[1025669,1025790],"length":1,"stats":{"Line":0}},{"line":121,"address":[1016885],"length":1,"stats":{"Line":2}},{"line":124,"address":[1028507,1028513,1027840],"length":1,"stats":{"Line":3}},{"line":125,"address":[1027847],"length":1,"stats":{"Line":1}},{"line":126,"address":[1027892],"length":1,"stats":{"Line":1}},{"line":127,"address":[1027903],"length":1,"stats":{"Line":1}},{"line":128,"address":[1028032,1027929],"length":1,"stats":{"Line":2}},{"line":129,"address":[1028224,1027988],"length":1,"stats":{"Line":1}},{"line":130,"address":[1027956,1028583],"length":1,"stats":{"Line":0}},{"line":132,"address":[1028439,1028526,1028604,1028243,1028300,1028354],"length":1,"stats":{"Line":1}},{"line":135,"address":[1016800,1016818],"length":1,"stats":{"Line":3}},{"line":136,"address":[1024743],"length":1,"stats":{"Line":1}},{"line":137,"address":[1024788],"length":1,"stats":{"Line":1}},{"line":138,"address":[1024799],"length":1,"stats":{"Line":1}},{"line":139,"address":[1024931,1024828],"length":1,"stats":{"Line":2}},{"line":140,"address":[1025123,1024887],"length":1,"stats":{"Line":1}},{"line":141,"address":[1025482,1024855],"length":1,"stats":{"Line":0}},{"line":143,"address":[1016837],"length":1,"stats":{"Line":1}},{"line":146,"address":[1017136,1017154],"length":1,"stats":{"Line":3}},{"line":147,"address":[1028695],"length":1,"stats":{"Line":1}},{"line":148,"address":[1028740],"length":1,"stats":{"Line":1}},{"line":149,"address":[1028751],"length":1,"stats":{"Line":1}},{"line":150,"address":[1028886,1028783],"length":1,"stats":{"Line":2}},{"line":151,"address":[1028842,1029078],"length":1,"stats":{"Line":1}},{"line":152,"address":[1028810,1029431],"length":1,"stats":{"Line":0}},{"line":154,"address":[1017173],"length":1,"stats":{"Line":1}},{"line":157,"address":[1017442,1017424],"length":1,"stats":{"Line":3}},{"line":158,"address":[1031431],"length":1,"stats":{"Line":1}},{"line":159,"address":[1031476],"length":1,"stats":{"Line":1}},{"line":160,"address":[1031487],"length":1,"stats":{"Line":1}},{"line":161,"address":[1031537,1031503],"length":1,"stats":{"Line":1}},{"line":163,"address":[1031630,1031509],"length":1,"stats":{"Line":0}},{"line":165,"address":[1017461],"length":1,"stats":{"Line":2}},{"line":168,"address":[1017472,1017490],"length":1,"stats":{"Line":3}},{"line":169,"address":[1031687],"length":1,"stats":{"Line":1}},{"line":170,"address":[1031732],"length":1,"stats":{"Line":1}},{"line":171,"address":[1031743],"length":1,"stats":{"Line":1}},{"line":172,"address":[1031872,1031769],"length":1,"stats":{"Line":2}},{"line":173,"address":[1032064,1031828],"length":1,"stats":{"Line":1}},{"line":174,"address":[1032423,1031796],"length":1,"stats":{"Line":0}},{"line":176,"address":[1032140,1032194,1032083,1032279,1032366,1032444],"length":1,"stats":{"Line":1}},{"line":179,"address":[1017298,1017280],"length":1,"stats":{"Line":3}},{"line":180,"address":[1030055],"length":1,"stats":{"Line":1}},{"line":181,"address":[1030100],"length":1,"stats":{"Line":1}},{"line":182,"address":[1030111],"length":1,"stats":{"Line":1}},{"line":183,"address":[1030140,1030243],"length":1,"stats":{"Line":2}},{"line":184,"address":[1030435,1030199],"length":1,"stats":{"Line":1}},{"line":185,"address":[1030794,1030167],"length":1,"stats":{"Line":0}},{"line":187,"address":[1017317],"length":1,"stats":{"Line":1}},{"line":190,"address":[1017184,1017202],"length":1,"stats":{"Line":3}},{"line":191,"address":[1029543],"length":1,"stats":{"Line":1}},{"line":192,"address":[1029588],"length":1,"stats":{"Line":1}},{"line":193,"address":[1029599],"length":1,"stats":{"Line":1}},{"line":194,"address":[1029649,1029615],"length":1,"stats":{"Line":1}},{"line":196,"address":[1029742,1029621],"length":1,"stats":{"Line":0}},{"line":198,"address":[1017221],"length":1,"stats":{"Line":2}},{"line":201,"address":[1032880,1033601,1033635],"length":1,"stats":{"Line":3}},{"line":202,"address":[1032887],"length":1,"stats":{"Line":1}},{"line":203,"address":[1032932],"length":1,"stats":{"Line":1}},{"line":204,"address":[1032943],"length":1,"stats":{"Line":1}},{"line":205,"address":[1032994,1033097],"length":1,"stats":{"Line":2}},{"line":206,"address":[1033297],"length":1,"stats":{"Line":1}},{"line":207,"address":[1033382,1033053],"length":1,"stats":{"Line":1}},{"line":208,"address":[1033658,1033021],"length":1,"stats":{"Line":0}},{"line":210,"address":[1033458,1033401,1033512,1033607,1033679],"length":1,"stats":{"Line":1}},{"line":213,"address":[1034659,1033904,1034625],"length":1,"stats":{"Line":3}},{"line":214,"address":[1033911],"length":1,"stats":{"Line":1}},{"line":215,"address":[1033956],"length":1,"stats":{"Line":1}},{"line":216,"address":[1033967],"length":1,"stats":{"Line":1}},{"line":217,"address":[1034018,1034121],"length":1,"stats":{"Line":2}},{"line":218,"address":[1034321],"length":1,"stats":{"Line":1}},{"line":219,"address":[1034406,1034077],"length":1,"stats":{"Line":1}},{"line":220,"address":[1034682,1034045],"length":1,"stats":{"Line":0}},{"line":222,"address":[1017701],"length":1,"stats":{"Line":1}},{"line":225,"address":[1017232,1017250],"length":1,"stats":{"Line":3}},{"line":226,"address":[1029799],"length":1,"stats":{"Line":1}},{"line":227,"address":[1029844],"length":1,"stats":{"Line":1}},{"line":228,"address":[1029855],"length":1,"stats":{"Line":1}},{"line":229,"address":[1029905,1029871],"length":1,"stats":{"Line":1}},{"line":231,"address":[1029998,1029877],"length":1,"stats":{"Line":0}},{"line":233,"address":[1029935],"length":1,"stats":{"Line":2}},{"line":236,"address":[1016896,1016914],"length":1,"stats":{"Line":3}},{"line":237,"address":[1025847],"length":1,"stats":{"Line":1}},{"line":238,"address":[1025892],"length":1,"stats":{"Line":1}},{"line":239,"address":[1025903],"length":1,"stats":{"Line":1}},{"line":240,"address":[1026032,1025929],"length":1,"stats":{"Line":2}},{"line":241,"address":[1026224,1025988],"length":1,"stats":{"Line":1}},{"line":242,"address":[1025956,1026465],"length":1,"stats":{"Line":0}},{"line":244,"address":[1026243,1026443,1026486,1026354,1026300],"length":1,"stats":{"Line":1}},{"line":247,"address":[1017376,1017394],"length":1,"stats":{"Line":3}},{"line":248,"address":[1031255],"length":1,"stats":{"Line":1}},{"line":249,"address":[1031300],"length":1,"stats":{"Line":1}},{"line":250,"address":[1017413],"length":1,"stats":{"Line":2}},{"line":253,"address":[1017520,1017538],"length":1,"stats":{"Line":3}},{"line":254,"address":[1032535],"length":1,"stats":{"Line":1}},{"line":255,"address":[1032646,1032582],"length":1,"stats":{"Line":2}},{"line":256,"address":[1017557],"length":1,"stats":{"Line":2}},{"line":259,"address":[1017346,1017328],"length":1,"stats":{"Line":3}},{"line":260,"address":[1030903],"length":1,"stats":{"Line":1}},{"line":261,"address":[1030950,1031029],"length":1,"stats":{"Line":2}},{"line":262,"address":[1017365],"length":1,"stats":{"Line":2}},{"line":265,"address":[1017616,1017634],"length":1,"stats":{"Line":3}},{"line":266,"address":[1033751],"length":1,"stats":{"Line":1}},{"line":267,"address":[1033825,1033776],"length":1,"stats":{"Line":2}},{"line":268,"address":[1017653],"length":1,"stats":{"Line":2}}],"covered":166,"coverable":182},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-common","src","error.rs"],"content":"//! Unified error handling for GitForce\n//!\n//! Uses thiserror for library errors and anyhow for contextual errors.\n//! Each error has a kind for programmatic error handling.\n\nuse serde::{Deserialize, Serialize};\nuse std::fmt;\nuse thiserror::Error;\n\n/// Error kinds for programmatic error handling\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ErrorKind {\n /// Entity not found\n NotFound,\n /// Authentication required or failed\n Authentication,\n /// Authorization denied\n Authorization,\n /// Already exists\n AlreadyExists,\n /// Invalid input or configuration\n InvalidInput,\n /// Database error\n Database,\n /// Git protocol error\n GitProtocol,\n /// Git repository error\n GitRepo,\n /// Event system error\n EventSystem,\n /// Storage error\n Storage,\n /// Sandbox execution error\n Sandbox,\n /// Network error\n Network,\n /// Timeout error\n Timeout,\n /// Internal error (bugs)\n Internal,\n /// Cancellation requested\n Cancelled,\n}\n\n/// Unified error type for GitForce\n#[derive(Debug, Error)]\n#[error(\"{kind}: {message}\")]\npub struct Error {\n pub kind: ErrorKind,\n pub message: String,\n #[source]\n pub source: Option>,\n}\n\nimpl Error {\n /// Create a new error with kind and message\n pub fn new(kind: ErrorKind, message: impl Into) -> Self {\n Self {\n kind,\n message: message.into(),\n source: None,\n }\n }\n\n /// Create a new error with kind, message, and source\n pub fn with_source(kind: ErrorKind, message: impl Into, source: E) -> Self\n where\n E: std::error::Error + Send + Sync + 'static,\n {\n Self {\n kind,\n message: message.into(),\n source: Some(Box::new(source)),\n }\n }\n\n /// Create a not found error\n pub fn not_found(entity: &str, id: impl std::fmt::Display) -> Self {\n Self::new(ErrorKind::NotFound, format!(\"{} not found: {}\", entity, id))\n }\n\n /// Create an already exists error\n pub fn already_exists(entity: &str, name: impl std::fmt::Display) -> Self {\n Self::new(\n ErrorKind::AlreadyExists,\n format!(\"{} already exists: {}\", entity, name),\n )\n }\n\n /// Create an invalid input error\n pub fn invalid_input(msg: impl Into) -> Self {\n Self::new(ErrorKind::InvalidInput, msg)\n }\n\n /// Create an internal error\n pub fn internal(msg: impl Into) -> Self {\n Self::new(ErrorKind::Internal, msg)\n }\n\n /// Create a database error\n pub fn database(msg: impl Into) -> Self {\n Self::new(ErrorKind::Database, msg)\n }\n\n /// Create a git error\n pub fn git(msg: impl Into) -> Self {\n Self::new(ErrorKind::GitRepo, msg)\n }\n\n /// Create an authentication error\n pub fn auth(msg: impl Into) -> Self {\n Self::new(ErrorKind::Authentication, msg)\n }\n\n /// Create an authorization error\n pub fn forbidden(msg: impl Into) -> Self {\n Self::new(ErrorKind::Authorization, msg)\n }\n\n /// Create a storage error\n pub fn storage(msg: impl Into) -> Self {\n Self::new(ErrorKind::Storage, msg)\n }\n\n /// Create a sandbox error\n pub fn sandbox(msg: impl Into) -> Self {\n Self::new(ErrorKind::Sandbox, msg)\n }\n\n /// Create a timeout error\n pub fn timeout(msg: impl Into) -> Self {\n Self::new(ErrorKind::Timeout, msg)\n }\n\n /// Create a cancelled error\n pub fn cancelled() -> Self {\n Self::new(ErrorKind::Cancelled, \"operation cancelled\")\n }\n\n /// Create an event system error\n pub fn event_system(msg: impl Into) -> Self {\n Self::new(ErrorKind::EventSystem, msg)\n }\n}\n\nimpl fmt::Display for ErrorKind {\n fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n match self {\n ErrorKind::NotFound => write!(f, \"not_found\"),\n ErrorKind::Authentication => write!(f, \"authentication\"),\n ErrorKind::Authorization => write!(f, \"authorization\"),\n ErrorKind::AlreadyExists => write!(f, \"already_exists\"),\n ErrorKind::InvalidInput => write!(f, \"invalid_input\"),\n ErrorKind::Database => write!(f, \"database\"),\n ErrorKind::GitProtocol => write!(f, \"git_protocol\"),\n ErrorKind::GitRepo => write!(f, \"git_repo\"),\n ErrorKind::EventSystem => write!(f, \"event_system\"),\n ErrorKind::Storage => write!(f, \"storage\"),\n ErrorKind::Sandbox => write!(f, \"sandbox\"),\n ErrorKind::Network => write!(f, \"network\"),\n ErrorKind::Timeout => write!(f, \"timeout\"),\n ErrorKind::Internal => write!(f, \"internal\"),\n ErrorKind::Cancelled => write!(f, \"cancelled\"),\n }\n }\n}\n\n/// Result type alias using GitForce's Error\npub type Result = std::result::Result;\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_error_creation() {\n let err = Error::not_found(\"repository\", \"abc-123\");\n assert_eq!(err.kind, ErrorKind::NotFound);\n assert!(err.message.contains(\"repository\"));\n assert!(err.message.contains(\"abc-123\"));\n }\n\n #[test]\n fn test_error_display() {\n let err = Error::invalid_input(\"name cannot be empty\");\n let display = format!(\"{}\", err);\n assert!(display.contains(\"invalid_input\"));\n assert!(display.contains(\"name cannot be empty\"));\n }\n\n #[test]\n fn test_error_source() {\n let inner = std::io::Error::new(std::io::ErrorKind::NotFound, \"file not found\");\n let err = Error::with_source(ErrorKind::Storage, \"failed to read\", inner);\n assert!(err.source.is_some());\n }\n\n #[test]\n fn test_error_kind_display() {\n assert_eq!(format!(\"{}\", ErrorKind::NotFound), \"not_found\");\n assert_eq!(format!(\"{}\", ErrorKind::Authentication), \"authentication\");\n assert_eq!(format!(\"{}\", ErrorKind::Authorization), \"authorization\");\n assert_eq!(format!(\"{}\", ErrorKind::AlreadyExists), \"already_exists\");\n assert_eq!(format!(\"{}\", ErrorKind::InvalidInput), \"invalid_input\");\n assert_eq!(format!(\"{}\", ErrorKind::Database), \"database\");\n assert_eq!(format!(\"{}\", ErrorKind::GitProtocol), \"git_protocol\");\n assert_eq!(format!(\"{}\", ErrorKind::GitRepo), \"git_repo\");\n assert_eq!(format!(\"{}\", ErrorKind::EventSystem), \"event_system\");\n assert_eq!(format!(\"{}\", ErrorKind::Storage), \"storage\");\n assert_eq!(format!(\"{}\", ErrorKind::Sandbox), \"sandbox\");\n assert_eq!(format!(\"{}\", ErrorKind::Network), \"network\");\n assert_eq!(format!(\"{}\", ErrorKind::Timeout), \"timeout\");\n assert_eq!(format!(\"{}\", ErrorKind::Internal), \"internal\");\n assert_eq!(format!(\"{}\", ErrorKind::Cancelled), \"cancelled\");\n }\n\n #[test]\n fn test_error_already_exists() {\n let err = Error::already_exists(\"repository\", \"my-repo\");\n assert_eq!(err.kind, ErrorKind::AlreadyExists);\n assert!(err.message.contains(\"my-repo\"));\n }\n\n #[test]\n fn test_error_internal() {\n let err = Error::internal(\"unexpected condition\");\n assert_eq!(err.kind, ErrorKind::Internal);\n assert!(err.message.contains(\"unexpected condition\"));\n }\n\n #[test]\n fn test_error_database() {\n let err = Error::database(\"connection failed\");\n assert_eq!(err.kind, ErrorKind::Database);\n assert!(err.message.contains(\"connection failed\"));\n }\n\n #[test]\n fn test_error_git() {\n let err = Error::git(\"repository not found\");\n assert_eq!(err.kind, ErrorKind::GitRepo);\n assert!(err.message.contains(\"repository not found\"));\n }\n\n #[test]\n fn test_error_auth() {\n let err = Error::auth(\"invalid token\");\n assert_eq!(err.kind, ErrorKind::Authentication);\n assert!(err.message.contains(\"invalid token\"));\n }\n\n #[test]\n fn test_error_forbidden() {\n let err = Error::forbidden(\"access denied\");\n assert_eq!(err.kind, ErrorKind::Authorization);\n assert!(err.message.contains(\"access denied\"));\n }\n\n #[test]\n fn test_error_storage() {\n let err = Error::storage(\"disk full\");\n assert_eq!(err.kind, ErrorKind::Storage);\n assert!(err.message.contains(\"disk full\"));\n }\n\n #[test]\n fn test_error_sandbox() {\n let err = Error::sandbox(\"container failed to start\");\n assert_eq!(err.kind, ErrorKind::Sandbox);\n assert!(err.message.contains(\"container failed to start\"));\n }\n\n #[test]\n fn test_error_timeout() {\n let err = Error::timeout(\"operation timed out\");\n assert_eq!(err.kind, ErrorKind::Timeout);\n assert!(err.message.contains(\"operation timed out\"));\n }\n\n #[test]\n fn test_error_cancelled() {\n let err = Error::cancelled();\n assert_eq!(err.kind, ErrorKind::Cancelled);\n assert!(err.message.contains(\"operation cancelled\"));\n }\n\n #[test]\n fn test_error_event_system() {\n let err = Error::event_system(\"event channel closed\");\n assert_eq!(err.kind, ErrorKind::EventSystem);\n assert!(err.message.contains(\"event channel closed\"));\n }\n\n #[test]\n fn test_error_new() {\n let err = Error::new(ErrorKind::NotFound, \"test message\");\n assert_eq!(err.kind, ErrorKind::NotFound);\n assert_eq!(err.message, \"test message\");\n assert!(err.source.is_none());\n }\n\n #[test]\n fn test_error_clone() {\n let err = Error::not_found(\"repo\", \"123\");\n // Error doesn't implement Clone, but we can verify kind and message are correct\n assert_eq!(err.kind, ErrorKind::NotFound);\n assert_eq!(err.message, \"repo not found: 123\");\n }\n}\n","traces":[{"line":58,"address":[15745568],"length":1,"stats":{"Line":13}},{"line":61,"address":[9891129],"length":1,"stats":{"Line":14}},{"line":67,"address":[946341,946347,946096],"length":1,"stats":{"Line":1}},{"line":73,"address":[946168],"length":1,"stats":{"Line":1}},{"line":74,"address":[946219],"length":1,"stats":{"Line":1}},{"line":79,"address":[5447304,5447232],"length":1,"stats":{"Line":3}},{"line":80,"address":[947482,947557],"length":1,"stats":{"Line":6}},{"line":84,"address":[5446928,5446640],"length":1,"stats":{"Line":1}},{"line":86,"address":[],"length":0,"stats":{"Line":0}},{"line":87,"address":[946597,946522],"length":1,"stats":{"Line":2}},{"line":92,"address":[21402368,21402432],"length":1,"stats":{"Line":6}},{"line":93,"address":[10817276],"length":1,"stats":{"Line":7}},{"line":97,"address":[],"length":0,"stats":{"Line":2}},{"line":98,"address":[13853627,13853548],"length":1,"stats":{"Line":2}},{"line":102,"address":[6333648],"length":1,"stats":{"Line":1}},{"line":103,"address":[947323],"length":1,"stats":{"Line":1}},{"line":107,"address":[946768],"length":1,"stats":{"Line":3}},{"line":108,"address":[10817660],"length":1,"stats":{"Line":2}},{"line":112,"address":[],"length":0,"stats":{"Line":2}},{"line":113,"address":[],"length":0,"stats":{"Line":2}},{"line":117,"address":[],"length":0,"stats":{"Line":1}},{"line":118,"address":[947419],"length":1,"stats":{"Line":1}},{"line":122,"address":[22232288],"length":1,"stats":{"Line":2}},{"line":123,"address":[18679948],"length":1,"stats":{"Line":2}},{"line":127,"address":[947152],"length":1,"stats":{"Line":1}},{"line":128,"address":[947179],"length":1,"stats":{"Line":1}},{"line":132,"address":[],"length":0,"stats":{"Line":1}},{"line":133,"address":[],"length":0,"stats":{"Line":1}},{"line":137,"address":[6204000],"length":1,"stats":{"Line":1}},{"line":138,"address":[32257384],"length":1,"stats":{"Line":1}},{"line":142,"address":[],"length":0,"stats":{"Line":1}},{"line":143,"address":[2910652],"length":1,"stats":{"Line":1}},{"line":148,"address":[24865536],"length":1,"stats":{"Line":1}},{"line":149,"address":[19831026],"length":1,"stats":{"Line":1}},{"line":150,"address":[30338401],"length":1,"stats":{"Line":1}},{"line":151,"address":[945465],"length":1,"stats":{"Line":1}},{"line":152,"address":[6205329],"length":1,"stats":{"Line":1}},{"line":153,"address":[13396665],"length":1,"stats":{"Line":1}},{"line":154,"address":[15747425],"length":1,"stats":{"Line":1}},{"line":155,"address":[6511065],"length":1,"stats":{"Line":1}},{"line":156,"address":[7378241],"length":1,"stats":{"Line":1}},{"line":157,"address":[34708441],"length":1,"stats":{"Line":1}},{"line":158,"address":[15650961],"length":1,"stats":{"Line":1}},{"line":159,"address":[6621657],"length":1,"stats":{"Line":1}},{"line":160,"address":[34708561],"length":1,"stats":{"Line":1}},{"line":161,"address":[17781929],"length":1,"stats":{"Line":1}},{"line":162,"address":[34708638],"length":1,"stats":{"Line":1}},{"line":163,"address":[13397059],"length":1,"stats":{"Line":1}},{"line":164,"address":[17782040],"length":1,"stats":{"Line":1}}],"covered":48,"coverable":49},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-common","src","ids.rs"],"content":"//! UUID type definitions for all GitForce entities\n//!\n//! Each entity type has its own dedicated UUID type to prevent\n//! mixing up IDs across different entity types.\n\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Repository identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct RepoId(pub Uuid);\n\nimpl RepoId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for RepoId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for RepoId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for RepoId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: RepoId) -> Self {\n id.0\n }\n}\n\n/// Pipeline identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct PipelineId(pub Uuid);\n\nimpl PipelineId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for PipelineId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for PipelineId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for PipelineId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: PipelineId) -> Self {\n id.0\n }\n}\n\n/// Pipeline run identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct PipelineRunId(pub Uuid);\n\nimpl PipelineRunId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for PipelineRunId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for PipelineRunId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for PipelineRunId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: PipelineRunId) -> Self {\n id.0\n }\n}\n\n/// Job identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct JobId(pub Uuid);\n\nimpl JobId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for JobId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for JobId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for JobId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: JobId) -> Self {\n id.0\n }\n}\n\n/// Runner identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct RunnerId(pub Uuid);\n\nimpl RunnerId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for RunnerId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for RunnerId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for RunnerId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: RunnerId) -> Self {\n id.0\n }\n}\n\n/// Step identifier within a job\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct StepId(pub Uuid);\n\nimpl StepId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for StepId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for StepId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for StepId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: StepId) -> Self {\n id.0\n }\n}\n\n/// User identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct UserId(pub Uuid);\n\nimpl UserId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for UserId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for UserId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for UserId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\nimpl From for Uuid {\n fn from(id: UserId) -> Self {\n id.0\n }\n}\n\n/// Job status\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum JobStatus {\n Pending,\n Queued,\n Assigned,\n Running,\n Succeeded,\n Failed,\n Cancelled,\n TimedOut,\n}\n\nimpl JobStatus {\n /// Check if this status is terminal (no further transitions possible)\n pub fn is_terminal(&self) -> bool {\n matches!(\n self,\n JobStatus::Succeeded | JobStatus::Failed | JobStatus::Cancelled | JobStatus::TimedOut\n )\n }\n}\n\n/// Pipeline status\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum PipelineStatus {\n Pending,\n Running,\n Succeeded,\n Failed,\n Cancelled,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_repo_id_generation() {\n let id1 = RepoId::new();\n let id2 = RepoId::new();\n assert_ne!(id1, id2);\n // Verify it's a valid UUID (v4)\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_id_conversion() {\n let id = RepoId::new();\n let uuid: Uuid = id.into();\n let id2: RepoId = uuid.into();\n assert_eq!(id, id2);\n }\n\n #[test]\n fn test_id_display() {\n let id = RepoId::new();\n let display = format!(\"{}\", id);\n assert_eq!(display, id.0.to_string());\n }\n\n #[test]\n fn test_pipeline_id_generation() {\n let id1 = PipelineId::new();\n let id2 = PipelineId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_pipeline_run_id_generation() {\n let id1 = PipelineRunId::new();\n let id2 = PipelineRunId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_job_id_generation() {\n let id1 = JobId::new();\n let id2 = JobId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_runner_id_generation() {\n let id1 = RunnerId::new();\n let id2 = RunnerId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_step_id_generation() {\n let id1 = StepId::new();\n let id2 = StepId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_user_id_generation() {\n let id1 = UserId::new();\n let id2 = UserId::new();\n assert_ne!(id1, id2);\n assert!(id1.0.get_version_num() == 4);\n }\n\n #[test]\n fn test_all_id_types_have_unique_uuids() {\n let repo = RepoId::new();\n let pipeline = PipelineId::new();\n let run = PipelineRunId::new();\n let job = JobId::new();\n let runner = RunnerId::new();\n let step = StepId::new();\n let user = UserId::new();\n\n // All should be unique\n let ids = [repo.0, pipeline.0, run.0, job.0, runner.0, step.0, user.0];\n let mut sorted = ids;\n sorted.sort();\n for i in 1..sorted.len() {\n assert_ne!(sorted[i], sorted[i-1], \"IDs should be unique\");\n }\n }\n\n #[test]\n fn test_job_status_is_terminal() {\n assert!(!crate::JobStatus::Pending.is_terminal());\n assert!(!crate::JobStatus::Running.is_terminal());\n assert!(crate::JobStatus::Succeeded.is_terminal());\n assert!(crate::JobStatus::Failed.is_terminal());\n assert!(crate::JobStatus::Cancelled.is_terminal());\n assert!(crate::JobStatus::TimedOut.is_terminal());\n }\n\n #[test]\n fn test_pipeline_status_values() {\n use crate::PipelineStatus;\n assert!(!matches!(PipelineStatus::Pending, PipelineStatus::Running));\n assert!(!matches!(PipelineStatus::Running, PipelineStatus::Pending));\n }\n\n #[test]\n fn test_job_status_non_terminal() {\n assert!(!crate::JobStatus::Queued.is_terminal());\n assert!(!crate::JobStatus::Assigned.is_terminal());\n }\n\n #[test]\n fn test_pipeline_status_terminal() {\n use crate::PipelineStatus;\n // PipelineStatus doesn't have is_terminal, but we can verify the variants exist\n let _ = PipelineStatus::Pending;\n let _ = PipelineStatus::Running;\n let _ = PipelineStatus::Succeeded;\n let _ = PipelineStatus::Failed;\n let _ = PipelineStatus::Cancelled;\n }\n\n #[test]\n fn test_id_serialization() {\n // RepoId uses serde with #[serde(transparent)] so it serializes as a UUID string\n // We test that From and Into work correctly\n let uuid = uuid::Uuid::new_v4();\n let id: RepoId = uuid.into();\n let round_trip: Uuid = id.into();\n assert_eq!(uuid, round_trip);\n }\n\n #[test]\n fn test_pipeline_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: PipelineId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_pipeline_id_into_uuid() {\n let id = PipelineId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_pipeline_run_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: PipelineRunId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_pipeline_run_id_into_uuid() {\n let id = PipelineRunId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_job_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: JobId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_job_id_into_uuid() {\n let id = JobId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_runner_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: RunnerId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_runner_id_into_uuid() {\n let id = RunnerId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_step_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: StepId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_step_id_into_uuid() {\n let id = StepId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_user_id_from_uuid() {\n let uuid = Uuid::new_v4();\n let id: UserId = uuid.into();\n assert_eq!(id.0, uuid);\n }\n\n #[test]\n fn test_user_id_into_uuid() {\n let id = UserId::new();\n let uuid: Uuid = id.into();\n assert_eq!(uuid, id.0);\n }\n\n #[test]\n fn test_all_ids_default() {\n assert!(RepoId::default() != RepoId::new()); // Default calls new()\n assert!(PipelineId::default() != PipelineId::new());\n assert!(PipelineRunId::default() != PipelineRunId::new());\n assert!(JobId::default() != JobId::new());\n assert!(RunnerId::default() != RunnerId::new());\n assert!(StepId::default() != StepId::new());\n assert!(UserId::default() != UserId::new());\n }\n\n #[test]\n fn test_all_ids_display() {\n assert!(!format!(\"{}\", RepoId::new()).is_empty());\n assert!(!format!(\"{}\", PipelineId::new()).is_empty());\n assert!(!format!(\"{}\", PipelineRunId::new()).is_empty());\n assert!(!format!(\"{}\", JobId::new()).is_empty());\n assert!(!format!(\"{}\", RunnerId::new()).is_empty());\n assert!(!format!(\"{}\", StepId::new()).is_empty());\n assert!(!format!(\"{}\", UserId::new()).is_empty());\n }\n\n #[test]\n fn test_all_ids_debug() {\n let repo = RepoId::new();\n let pipeline = PipelineId::new();\n let run = PipelineRunId::new();\n let job = JobId::new();\n let runner = RunnerId::new();\n let step = StepId::new();\n let user = UserId::new();\n\n assert!(format!(\"{:?}\", repo).contains(\"RepoId\"));\n assert!(format!(\"{:?}\", pipeline).contains(\"PipelineId\"));\n assert!(format!(\"{:?}\", run).contains(\"PipelineRunId\"));\n assert!(format!(\"{:?}\", job).contains(\"JobId\"));\n assert!(format!(\"{:?}\", runner).contains(\"RunnerId\"));\n assert!(format!(\"{:?}\", step).contains(\"StepId\"));\n assert!(format!(\"{:?}\", user).contains(\"UserId\"));\n }\n\n #[test]\n fn test_all_ids_clone() {\n let repo = RepoId::new();\n let pipeline = PipelineId::new();\n let run = PipelineRunId::new();\n let job = JobId::new();\n let runner = RunnerId::new();\n let step = StepId::new();\n let user = UserId::new();\n\n assert_eq!(repo, repo.clone());\n assert_eq!(pipeline, pipeline.clone());\n assert_eq!(run, run.clone());\n assert_eq!(job, job.clone());\n assert_eq!(runner, runner.clone());\n assert_eq!(step, step.clone());\n assert_eq!(user, user.clone());\n }\n\n #[test]\n fn test_id_hash_trait() {\n use std::collections::HashSet;\n let mut set: HashSet = HashSet::new();\n set.insert(RepoId::new());\n set.insert(RepoId::new());\n assert!(set.len() >= 1);\n }\n\n #[test]\n fn test_job_status_debug() {\n assert!(format!(\"{:?}\", JobStatus::Pending).contains(\"Pending\"));\n assert!(format!(\"{:?}\", JobStatus::Succeeded).contains(\"Succeeded\"));\n }\n\n #[test]\n fn test_pipeline_status_debug() {\n assert!(format!(\"{:?}\", PipelineStatus::Pending).contains(\"Pending\"));\n assert!(format!(\"{:?}\", PipelineStatus::Failed).contains(\"Failed\"));\n }\n\n #[test]\n fn test_job_status_partialeq() {\n assert_eq!(JobStatus::Pending, JobStatus::Pending);\n assert_ne!(JobStatus::Pending, JobStatus::Running);\n }\n\n #[test]\n fn test_pipeline_status_partialeq() {\n assert_eq!(PipelineStatus::Pending, PipelineStatus::Pending);\n assert_ne!(PipelineStatus::Pending, PipelineStatus::Running);\n }\n}\n","traces":[{"line":15,"address":[7371584],"length":1,"stats":{"Line":24}},{"line":16,"address":[33821406],"length":1,"stats":{"Line":30}},{"line":21,"address":[7850544],"length":1,"stats":{"Line":1}},{"line":22,"address":[33823640],"length":1,"stats":{"Line":1}},{"line":27,"address":[31158048],"length":1,"stats":{"Line":12}},{"line":28,"address":[7372521],"length":1,"stats":{"Line":11}},{"line":33,"address":[24869968],"length":1,"stats":{"Line":3}},{"line":34,"address":[13393219],"length":1,"stats":{"Line":3}},{"line":39,"address":[15647600],"length":1,"stats":{"Line":1}},{"line":40,"address":[6625571],"length":1,"stats":{"Line":1}},{"line":50,"address":[17782592],"length":1,"stats":{"Line":12}},{"line":51,"address":[32259742],"length":1,"stats":{"Line":14}},{"line":56,"address":[24869776],"length":1,"stats":{"Line":1}},{"line":57,"address":[6625096],"length":1,"stats":{"Line":1}},{"line":62,"address":[15742976],"length":1,"stats":{"Line":4}},{"line":63,"address":[17784937],"length":1,"stats":{"Line":3}},{"line":68,"address":[33824608],"length":1,"stats":{"Line":3}},{"line":69,"address":[31160355],"length":1,"stats":{"Line":3}},{"line":74,"address":[30342976],"length":1,"stats":{"Line":1}},{"line":75,"address":[6202115],"length":1,"stats":{"Line":1}},{"line":85,"address":[33821264],"length":1,"stats":{"Line":16}},{"line":86,"address":[6622078],"length":1,"stats":{"Line":28}},{"line":91,"address":[17785808],"length":1,"stats":{"Line":1}},{"line":92,"address":[33824424],"length":1,"stats":{"Line":1}},{"line":97,"address":[6624976],"length":1,"stats":{"Line":4}},{"line":98,"address":[31159945],"length":1,"stats":{"Line":3}},{"line":103,"address":[933392],"length":1,"stats":{"Line":3}},{"line":104,"address":[32263171],"length":1,"stats":{"Line":3}},{"line":109,"address":[32263232],"length":1,"stats":{"Line":1}},{"line":110,"address":[19828387],"length":1,"stats":{"Line":1}},{"line":120,"address":[6511696],"length":1,"stats":{"Line":19}},{"line":121,"address":[930094],"length":1,"stats":{"Line":29}},{"line":126,"address":[6200864],"length":1,"stats":{"Line":1}},{"line":127,"address":[7373624],"length":1,"stats":{"Line":1}},{"line":132,"address":[19825824],"length":1,"stats":{"Line":19}},{"line":133,"address":[32260697],"length":1,"stats":{"Line":19}},{"line":138,"address":[34712512],"length":1,"stats":{"Line":6}},{"line":139,"address":[6201891],"length":1,"stats":{"Line":6}},{"line":144,"address":[7374928],"length":1,"stats":{"Line":1}},{"line":145,"address":[32263267],"length":1,"stats":{"Line":1}},{"line":155,"address":[24867072],"length":1,"stats":{"Line":11}},{"line":156,"address":[15741054],"length":1,"stats":{"Line":14}},{"line":161,"address":[15646944],"length":1,"stats":{"Line":1}},{"line":162,"address":[32262648],"length":1,"stats":{"Line":1}},{"line":167,"address":[34711312],"length":1,"stats":{"Line":7}},{"line":168,"address":[932025],"length":1,"stats":{"Line":7}},{"line":173,"address":[31160320],"length":1,"stats":{"Line":4}},{"line":174,"address":[34712643],"length":1,"stats":{"Line":5}},{"line":179,"address":[24870352],"length":1,"stats":{"Line":1}},{"line":180,"address":[24870355],"length":1,"stats":{"Line":1}},{"line":190,"address":[7848368],"length":1,"stats":{"Line":1}},{"line":191,"address":[15740926],"length":1,"stats":{"Line":1}},{"line":196,"address":[19827376],"length":1,"stats":{"Line":1}},{"line":197,"address":[7373896],"length":1,"stats":{"Line":1}},{"line":202,"address":[32261056],"length":1,"stats":{"Line":1}},{"line":203,"address":[931305],"length":1,"stats":{"Line":1}},{"line":208,"address":[34712576],"length":1,"stats":{"Line":1}},{"line":209,"address":[24870003],"length":1,"stats":{"Line":1}},{"line":214,"address":[31160544],"length":1,"stats":{"Line":1}},{"line":215,"address":[24870291],"length":1,"stats":{"Line":1}},{"line":225,"address":[17782912],"length":1,"stats":{"Line":28}},{"line":226,"address":[15644366],"length":1,"stats":{"Line":20}},{"line":231,"address":[30342032],"length":1,"stats":{"Line":1}},{"line":232,"address":[6201176],"length":1,"stats":{"Line":1}},{"line":237,"address":[15645504],"length":1,"stats":{"Line":20}},{"line":238,"address":[13391433],"length":1,"stats":{"Line":20}},{"line":243,"address":[31160288],"length":1,"stats":{"Line":3}},{"line":244,"address":[7851459],"length":1,"stats":{"Line":3}},{"line":249,"address":[15744288],"length":1,"stats":{"Line":1}},{"line":250,"address":[15744291],"length":1,"stats":{"Line":1}},{"line":269,"address":[34709712],"length":1,"stats":{"Line":2}},{"line":270,"address":[7848568],"length":1,"stats":{"Line":3}},{"line":271,"address":[15741109],"length":1,"stats":{"Line":2}}],"covered":73,"coverable":73},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-common","src","lib.rs"],"content":"//! GitForce Common Library\n//!\n//! Shared primitives for all GitForce components including:\n//! - UUID types for all entities\n//! - Unified error handling\n//! - Result type aliases\n//! - Time utilities\n\npub mod error;\npub mod ids;\npub mod time;\npub mod result;\n\npub use error::{Error, ErrorKind, Result};\npub use ids::{JobId, JobStatus, PipelineId, PipelineRunId, PipelineStatus, RepoId, RunnerId, StepId, UserId};\npub use time::DateTime;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-common","src","result.rs"],"content":"//! Result type alias for GitForce\n//!\n//! Provides a convenient Result type alias using GitForce's Error type.\n\npub use super::error::Error;\n\n/// Result type alias using GitForce's Error\npub type Result = std::result::Result;\n\n/// Extension trait for Result operations\npub trait ResultExt {\n /// Map the error to a different type using a closure\n fn map_err_context(self, f: F) -> std::result::Result\n where\n F: FnOnce(E) -> C;\n\n /// Convert to a different error type\n fn context(self, _context: C) -> std::result::Result\n where\n E: Into;\n}\n\nimpl ResultExt for std::result::Result {\n fn map_err_context(self, f: F) -> std::result::Result\n where\n F: FnOnce(E) -> C,\n {\n self.map_err(f)\n }\n\n fn context(self, _context: C) -> std::result::Result\n where\n E: Into,\n {\n self.map_err(Into::into)\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_result_map_err_context() {\n let result: std::result::Result = Err(\"original error\");\n let mapped = result.map_err_context(|e| format!(\"wrapped: {}\", e));\n assert!(mapped.is_err());\n assert_eq!(mapped.unwrap_err(), \"wrapped: original error\");\n }\n\n #[test]\n fn test_result_context() {\n // When E and C are the same type, context doesn't transform the error\n let result: std::result::Result = Err(\"error\");\n let with_context = result.context(\"context info\");\n assert!(with_context.is_err());\n // E: Into where both are &str just passes through\n assert_eq!(with_context.unwrap_err(), \"error\");\n }\n\n #[test]\n fn test_result_context_ok() {\n let result: std::result::Result = Ok(42);\n let with_context = result.context(\"context info\");\n assert!(with_context.is_ok());\n assert_eq!(with_context.unwrap(), 42);\n }\n\n #[test]\n fn test_result_map_err_context_ok() {\n let result: std::result::Result = Ok(100);\n let mapped = result.map_err_context(|e| format!(\"error: {}\", e));\n assert!(mapped.is_ok());\n assert_eq!(mapped.unwrap(), 100);\n }\n}\n","traces":[{"line":24,"address":[938720,938784],"length":1,"stats":{"Line":2}},{"line":28,"address":[938732,938796],"length":1,"stats":{"Line":2}},{"line":31,"address":[938848,938890],"length":1,"stats":{"Line":1}},{"line":35,"address":[938870],"length":1,"stats":{"Line":1}}],"covered":4,"coverable":4},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-common","src","time.rs"],"content":"//! Time utilities for GitForce\n//!\n//! Provides timezone-aware datetime types and utilities.\n\nuse chrono::{DateTime as ChronoDateTime, Utc};\n\n/// GitForge's standard timestamp type (UTC)\npub type DateTime = ChronoDateTime;\n\n/// Get current UTC timestamp\npub fn now() -> DateTime {\n Utc::now()\n}\n\n/// Get current Unix timestamp in milliseconds\npub fn unix_timestamp_ms() -> i64 {\n Utc::now().timestamp_millis()\n}\n\n/// Get current Unix timestamp in seconds\npub fn unix_timestamp() -> i64 {\n Utc::now().timestamp()\n}\n\n/// Calculate duration between two timestamps in milliseconds\npub fn duration_ms(start: DateTime, end: DateTime) -> i64 {\n (end - start).num_milliseconds()\n}\n\n/// Check if a timestamp is older than the given duration\npub fn is_older_than(timestamp: DateTime, duration: chrono::Duration) -> bool {\n Utc::now() - timestamp > duration\n}\n\n/// Duration shorthand constructors\npub mod duration {\n use chrono::Duration;\n\n pub fn seconds(s: i64) -> Duration {\n Duration::seconds(s)\n }\n\n pub fn minutes(m: i64) -> Duration {\n Duration::minutes(m)\n }\n\n pub fn hours(h: i64) -> Duration {\n Duration::hours(h)\n }\n\n pub fn days(d: i64) -> Duration {\n Duration::days(d)\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_now() {\n let ts = now();\n assert!(ts <= Utc::now());\n }\n\n #[test]\n fn test_unix_timestamp() {\n let ts = unix_timestamp();\n assert!(ts > 0);\n assert_eq!(ts, Utc::now().timestamp());\n }\n\n #[test]\n fn test_unix_timestamp_ms() {\n let ts = unix_timestamp_ms();\n assert!(ts > 0);\n }\n\n #[test]\n fn test_duration_helpers() {\n assert_eq!(duration::seconds(60), duration::seconds(60));\n assert_eq!(duration::minutes(5), duration::minutes(5));\n assert_eq!(duration::hours(2), duration::hours(2));\n assert_eq!(duration::days(1), duration::days(1));\n }\n\n #[test]\n fn test_is_older_than() {\n let old = Utc::now() - chrono::Duration::hours(2);\n let recent = Utc::now() - chrono::Duration::minutes(5);\n\n assert!(is_older_than(old, chrono::Duration::hours(1)));\n assert!(!is_older_than(recent, chrono::Duration::hours(1)));\n }\n\n #[test]\n fn test_duration_ms() {\n let start = Utc::now();\n let end = start + chrono::Duration::seconds(5);\n assert_eq!(duration_ms(start, end), 5000);\n }\n\n #[test]\n fn test_duration_helpers_individual() {\n assert_eq!(duration::seconds(30).num_seconds(), 30);\n assert_eq!(duration::minutes(10).num_minutes(), 10);\n assert_eq!(duration::hours(2).num_hours(), 2);\n assert_eq!(duration::days(1).num_days(), 1);\n }\n\n #[test]\n fn test_duration_comparison() {\n let s1 = duration::seconds(60);\n let m1 = duration::minutes(1);\n let h1 = duration::hours(1);\n let d1 = duration::days(1);\n\n // All equal\n assert_eq!(s1, m1);\n assert_eq!(m1, chrono::Duration::minutes(1));\n assert_eq!(h1, chrono::Duration::hours(1));\n assert_eq!(d1, chrono::Duration::days(1));\n }\n\n #[test]\n fn test_is_older_than_edge_cases() {\n let now = Utc::now();\n assert!(is_older_than(now - chrono::Duration::seconds(1), chrono::Duration::seconds(0)));\n assert!(!is_older_than(now, chrono::Duration::days(1)));\n }\n}\n","traces":[{"line":11,"address":[923696],"length":1,"stats":{"Line":1}},{"line":12,"address":[923704],"length":1,"stats":{"Line":1}},{"line":16,"address":[923664],"length":1,"stats":{"Line":1}},{"line":17,"address":[923668],"length":1,"stats":{"Line":1}},{"line":21,"address":[923632],"length":1,"stats":{"Line":1}},{"line":22,"address":[923636],"length":1,"stats":{"Line":1}},{"line":26,"address":[923456],"length":1,"stats":{"Line":1}},{"line":27,"address":[923460],"length":1,"stats":{"Line":1}},{"line":31,"address":[923536],"length":1,"stats":{"Line":1}},{"line":32,"address":[923554],"length":1,"stats":{"Line":1}},{"line":39,"address":[953296],"length":1,"stats":{"Line":2}},{"line":40,"address":[953301],"length":1,"stats":{"Line":3}},{"line":43,"address":[953264],"length":1,"stats":{"Line":3}},{"line":44,"address":[953269],"length":1,"stats":{"Line":3}},{"line":47,"address":[953232],"length":1,"stats":{"Line":3}},{"line":48,"address":[953237],"length":1,"stats":{"Line":3}},{"line":51,"address":[953200],"length":1,"stats":{"Line":3}},{"line":52,"address":[953205],"length":1,"stats":{"Line":3}}],"covered":18,"coverable":18},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","git_protocol","http.rs"],"content":"//! HTTP Git protocol handler\n//!\n//! Implements the Smart HTTP protocol for git\n\nuse super::GitProtocolHandler;\nuse crate::storage::StorageBackend;\nuse async_trait::async_trait;\nuse gitforce_common::{RepoId, Result};\nuse std::sync::Arc;\n\n/// HTTP Git protocol handler\npub struct HttpGitHandler {\n storage: Arc,\n}\n\nimpl HttpGitHandler {\n pub fn new(storage: S) -> Self {\n Self {\n storage: Arc::new(storage),\n }\n }\n}\n\n#[async_trait]\nimpl GitProtocolHandler for HttpGitHandler {\n async fn upload_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result> {\n // Open the repository\n let _repo = self.storage.open(repo_id).await?;\n\n tracing::debug!(\n \"upload_pack for repo {} ({} bytes input)\",\n repo_id,\n input.len()\n );\n\n // For smart HTTP protocol, upload-pack request contains the ref discovery\n // We return the refs info as a pkt-line format response\n // The actual pack file generation would be done by git cli in a real implementation\n // For now, return an empty pack negotiator response\n let response = b\"0000\".to_vec();\n Ok(response)\n }\n\n async fn receive_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result> {\n // Open the repository\n let _repo = self.storage.open(repo_id).await?;\n\n tracing::debug!(\n \"receive_pack for repo {} ({} bytes input)\",\n repo_id,\n input.len()\n );\n\n // receive-pack handles push operations\n // In a full implementation, this would process the pack data and update refs\n // Return acknowledgment\n let response = b\"0000\".to_vec();\n Ok(response)\n }\n}\n\n/// Parse Content-Type header for git protocol\npub fn parse_content_type(content_type: &str) -> Option<&str> {\n if content_type.contains(';') {\n Some(content_type.split(';').next()?.trim())\n } else {\n Some(content_type.trim())\n }\n}\n\n/// Parse git protocol service name from URL path\npub fn parse_service(service_path: &str) -> Option<(String, String)> {\n let parts: Vec<&str> = service_path.trim_start_matches('/').split('/').collect();\n if parts.len() >= 2 {\n let service = parts[0].to_string();\n let repo_and_path = &parts[1..];\n let repo_path = repo_and_path.join(\"/\");\n Some((service, repo_path))\n } else {\n None\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_parse_content_type() {\n assert_eq!(parse_content_type(\"application/x-git-upload-pack-request\"), Some(\"application/x-git-upload-pack-request\"));\n assert_eq!(parse_content_type(\"text/plain; charset=utf-8\"), Some(\"text/plain\"));\n }\n\n #[test]\n fn test_parse_content_type_edge_cases() {\n // Empty string\n assert_eq!(parse_content_type(\"\"), Some(\"\"));\n // Just whitespace - trims to empty\n assert_eq!(parse_content_type(\" \"), Some(\"\"));\n // Multiple semicolons\n assert_eq!(parse_content_type(\"text/plain; charset=utf-8; boundary=abc\"), Some(\"text/plain\"));\n }\n\n #[test]\n fn test_parse_service() {\n assert_eq!(\n parse_service(\"/git-upload-pack/owner/repo\"),\n Some((\"git-upload-pack\".to_string(), \"owner/repo\".to_string()))\n );\n assert_eq!(\n parse_service(\"git-receive-pack/owner/repo.git/info/refs\"),\n Some((\"git-receive-pack\".to_string(), \"owner/repo.git/info/refs\".to_string()))\n );\n }\n\n #[test]\n fn test_parse_service_edge_cases() {\n // No leading slash\n assert_eq!(\n parse_service(\"git-upload-pack/repo\"),\n Some((\"git-upload-pack\".to_string(), \"repo\".to_string()))\n );\n // Deep path\n assert_eq!(\n parse_service(\"/git-upload-pack/owner/repo/path/to/refs\"),\n Some((\"git-upload-pack\".to_string(), \"owner/repo/path/to/refs\".to_string()))\n );\n // Single segment (should return None)\n assert_eq!(parse_service(\"git-upload-pack\"), None);\n // Empty\n assert_eq!(parse_service(\"\"), None);\n }\n\n #[test]\n fn test_parse_service_empty_path() {\n assert_eq!(parse_service(\"/\"), None);\n assert_eq!(parse_service(\"\"), None);\n }\n\n #[tokio::test]\n async fn test_http_git_handler_upload_pack() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = HttpGitHandler::new(storage.clone());\n\n let repo_id = RepoId::new();\n let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await;\n // Should fail because repo doesn't exist\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_http_git_handler_receive_pack() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = HttpGitHandler::new(storage.clone());\n\n let repo_id = RepoId::new();\n let result = handler.receive_pack(repo_id, vec![1, 2, 3]).await;\n // Should fail because repo doesn't exist\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_http_git_handler_with_existing_repo() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = HttpGitHandler::new(storage.clone());\n\n // Create a repo first\n let repo_id = RepoId::new();\n storage.create(repo_id).await.unwrap();\n\n // Now upload_pack should work and return a valid response\n let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await;\n assert!(result.is_ok());\n let response = result.unwrap();\n // Response is a pkt-line format response (empty pkt-line is \"0000\")\n assert_eq!(response, b\"0000\");\n }\n\n #[tokio::test]\n async fn test_http_git_handler_receive_pack_with_existing_repo() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = HttpGitHandler::new(storage.clone());\n\n // Create a repo first\n let repo_id = RepoId::new();\n storage.create(repo_id).await.unwrap();\n\n // Now receive_pack should work and return a valid response\n let result = handler.receive_pack(repo_id, vec![]).await;\n assert!(result.is_ok());\n let response = result.unwrap();\n // Response is a pkt-line acknowledgment\n assert_eq!(response, b\"0000\");\n }\n\n #[test]\n fn test_http_git_handler_creation() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let _handler = HttpGitHandler::new(storage);\n // Handler created successfully\n }\n\n #[test]\n fn test_parse_content_type_with_charset() {\n // More charset variations\n assert_eq!(parse_content_type(\"application/json;charset=utf-8\"), Some(\"application/json\"));\n assert_eq!(parse_content_type(\"text/html; charset=ISO-8859-1\"), Some(\"text/html\"));\n }\n\n #[test]\n fn test_parse_service_various_paths() {\n // Various Git URL formats\n assert_eq!(\n parse_service(\"/git-upload-pack/owner/project.git\"),\n Some((\"git-upload-pack\".to_string(), \"owner/project.git\".to_string()))\n );\n assert_eq!(\n parse_service(\"git-receive-pack/my-org/my-repo\"),\n Some((\"git-receive-pack\".to_string(), \"my-org/my-repo\".to_string()))\n );\n }\n}\n","traces":[{"line":17,"address":[5583760],"length":1,"stats":{"Line":5}},{"line":19,"address":[],"length":0,"stats":{"Line":5}},{"line":26,"address":[5554671,5554636],"length":1,"stats":{"Line":6}},{"line":32,"address":[5577419,5577142,5577028,5577244],"length":1,"stats":{"Line":2}},{"line":34,"address":[],"length":0,"stats":{"Line":0}},{"line":35,"address":[],"length":0,"stats":{"Line":0}},{"line":36,"address":[],"length":0,"stats":{"Line":0}},{"line":37,"address":[],"length":0,"stats":{"Line":0}},{"line":44,"address":[],"length":0,"stats":{"Line":1}},{"line":45,"address":[],"length":0,"stats":{"Line":1}},{"line":48,"address":[5554940,5554975],"length":1,"stats":{"Line":6}},{"line":54,"address":[],"length":0,"stats":{"Line":2}},{"line":56,"address":[],"length":0,"stats":{"Line":0}},{"line":57,"address":[],"length":0,"stats":{"Line":0}},{"line":58,"address":[],"length":0,"stats":{"Line":0}},{"line":59,"address":[],"length":0,"stats":{"Line":0}},{"line":65,"address":[],"length":0,"stats":{"Line":1}},{"line":66,"address":[],"length":0,"stats":{"Line":1}},{"line":71,"address":[5422752],"length":1,"stats":{"Line":3}},{"line":72,"address":[5422785,5422992,5422825],"length":1,"stats":{"Line":6}},{"line":73,"address":[5422933,5422837],"length":1,"stats":{"Line":1}},{"line":75,"address":[5422810],"length":1,"stats":{"Line":2}},{"line":80,"address":[5422725,5422112,5422719],"length":1,"stats":{"Line":4}},{"line":81,"address":[5422171],"length":1,"stats":{"Line":4}},{"line":82,"address":[5422306,5422714,5422330,5422244],"length":1,"stats":{"Line":12}},{"line":83,"address":[5422332,5422389],"length":1,"stats":{"Line":6}},{"line":84,"address":[5422415,5422503],"length":1,"stats":{"Line":4}},{"line":85,"address":[5422519],"length":1,"stats":{"Line":3}},{"line":86,"address":[5422552],"length":1,"stats":{"Line":3}},{"line":88,"address":[5422317],"length":1,"stats":{"Line":1}}],"covered":22,"coverable":30},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","git_protocol","mod.rs"],"content":"//! Git protocol handlers\n\npub mod http;\npub mod ssh;\n\nuse gitforce_common::{RepoId, Result};\nuse std::future::Future;\n\n/// Git protocol handler trait\n#[async_trait::async_trait]\npub trait GitProtocolHandler: Send + Sync {\n /// Handle a git upload-pack request (git clone/fetch)\n async fn upload_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result>;\n\n /// Handle a git receive-pack request (git push)\n async fn receive_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result>;\n}\n\n/// Async function wrapper for GitProtocolHandler\npub struct FnHandler {\n f: F,\n}\n\nimpl FnHandler {\n pub fn new(f: F) -> Self {\n Self { f }\n }\n}\n\n#[async_trait::async_trait]\nimpl GitProtocolHandler for FnHandler\nwhere\n F: Fn(RepoId, Vec) -> R + Send + Sync,\n R: Future>> + Send + 'static,\n{\n async fn upload_pack(&self, repo_id: RepoId, input: Vec) -> Result> {\n (self.f)(repo_id, input).await\n }\n\n async fn receive_pack(&self, repo_id: RepoId, input: Vec) -> Result> {\n (self.f)(repo_id, input).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_fn_handler_upload_pack() {\n let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move {\n Ok(input)\n });\n\n let repo_id = RepoId::new();\n let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await;\n assert!(result.is_ok());\n assert_eq!(result.unwrap(), vec![1, 2, 3]);\n }\n\n #[tokio::test]\n async fn test_fn_handler_receive_pack() {\n let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move {\n Ok(input)\n });\n\n let repo_id = RepoId::new();\n let result = handler.receive_pack(repo_id, vec![4, 5, 6]).await;\n assert!(result.is_ok());\n assert_eq!(result.unwrap(), vec![4, 5, 6]);\n }\n\n #[tokio::test]\n async fn test_fn_handler_empty_input() {\n let handler = FnHandler::new(|_repo_id: RepoId, input: Vec| async move {\n Ok(input)\n });\n\n let repo_id = RepoId::new();\n let result = handler.upload_pack(repo_id, vec![]).await;\n assert!(result.is_ok());\n assert!(result.unwrap().is_empty());\n }\n\n #[test]\n fn test_fn_handler_new() {\n let handler = FnHandler::new(|_repo_id: RepoId, _input: Vec| async move {\n Ok::, gitforce_common::Error>(vec![])\n });\n assert!(matches!(handler, FnHandler { .. }));\n }\n}\n","traces":[{"line":33,"address":[],"length":0,"stats":{"Line":0}},{"line":44,"address":[],"length":0,"stats":{"Line":10}},{"line":45,"address":[],"length":0,"stats":{"Line":4}},{"line":48,"address":[],"length":0,"stats":{"Line":5}},{"line":49,"address":[],"length":0,"stats":{"Line":2}}],"covered":4,"coverable":5},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","git_protocol","ssh.rs"],"content":"//! SSH Git protocol handler\n//!\n//! Implements the SSH git protocol\n\nuse super::GitProtocolHandler;\nuse crate::storage::StorageBackend;\nuse async_trait::async_trait;\nuse gitforce_common::{RepoId, Result};\nuse std::sync::Arc;\n\n/// SSH Git protocol handler\npub struct SshGitHandler {\n storage: Arc,\n}\n\nimpl SshGitHandler {\n pub fn new(storage: S) -> Self {\n Self {\n storage: Arc::new(storage),\n }\n }\n}\n\n#[async_trait]\nimpl GitProtocolHandler for SshGitHandler {\n async fn upload_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result> {\n let _repo = self.storage.open(repo_id).await?;\n\n tracing::debug!(\n \"ssh upload_pack for repo {} ({} bytes input)\",\n repo_id,\n input.len()\n );\n\n // SSH upload-pack protocol - return refs info in pkt-line format\n // The actual pack file generation would be done by git cli in a real implementation\n let response = b\"0000\".to_vec();\n Ok(response)\n }\n\n async fn receive_pack(\n &self,\n repo_id: RepoId,\n input: Vec,\n ) -> Result> {\n let _repo = self.storage.open(repo_id).await?;\n\n tracing::debug!(\n \"ssh receive_pack for repo {} ({} bytes input)\",\n repo_id,\n input.len()\n );\n\n // SSH receive-pack protocol - return acknowledgment in pkt-line format\n let response = b\"0000\".to_vec();\n Ok(response)\n }\n}\n\n/// Extract command from SSH original_command\npub fn parse_ssh_command(cmd: &str) -> Option<(&str, &str)> {\n let parts: Vec<&str> = cmd.split_whitespace().collect();\n if parts.len() >= 2 {\n let command = parts[0];\n let repo_path = parts[1];\n Some((command, repo_path))\n } else {\n None\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_parse_ssh_command() {\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /owner/repo.git\"),\n Some((\"git-upload-pack\", \"/owner/repo.git\"))\n );\n assert_eq!(\n parse_ssh_command(\"git-receive-pack owner/repo\"),\n Some((\"git-receive-pack\", \"owner/repo\"))\n );\n }\n\n #[test]\n fn test_parse_ssh_command_edge_cases() {\n // Single word\n assert_eq!(parse_ssh_command(\"git-upload-pack\"), None);\n // Empty string\n assert_eq!(parse_ssh_command(\"\"), None);\n // Extra whitespace\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /repo\"),\n Some((\"git-upload-pack\", \"/repo\"))\n );\n // Multiple spaces between\n assert_eq!(\n parse_ssh_command(\"git-receive-pack owner/repo\"),\n Some((\"git-receive-pack\", \"owner/repo\"))\n );\n // Deep path\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /owner/repo/path/to/refs\"),\n Some((\"git-upload-pack\", \"/owner/repo/path/to/refs\"))\n );\n }\n\n #[test]\n fn test_parse_ssh_command_with_special_chars() {\n // Dot in repo name\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /repo.with.dots.git\"),\n Some((\"git-upload-pack\", \"/repo.with.dots.git\"))\n );\n // Underscore and hyphen\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /my_repo-name\"),\n Some((\"git-upload-pack\", \"/my_repo-name\"))\n );\n }\n\n #[test]\n fn test_parse_ssh_command_only_whitespace() {\n assert_eq!(parse_ssh_command(\" \"), None);\n }\n\n #[test]\n fn test_ssh_git_handler_creation() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let _handler = SshGitHandler::new(storage);\n // Handler created successfully\n }\n\n #[tokio::test]\n async fn test_ssh_git_handler_upload_pack() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = SshGitHandler::new(storage.clone());\n\n let repo_id = RepoId::new();\n let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await;\n // Should fail because repo doesn't exist\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_ssh_git_handler_receive_pack() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = SshGitHandler::new(storage.clone());\n\n let repo_id = RepoId::new();\n let result = handler.receive_pack(repo_id, vec![1, 2, 3]).await;\n // Should fail because repo doesn't exist\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_ssh_git_handler_with_existing_repo() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = SshGitHandler::new(storage.clone());\n\n // Create a repo first\n let repo_id = RepoId::new();\n storage.create(repo_id).await.unwrap();\n\n // Now upload_pack should work and return a valid response\n let result = handler.upload_pack(repo_id, vec![1, 2, 3]).await;\n assert!(result.is_ok());\n let response = result.unwrap();\n // Response is a pkt-line format response (empty pkt-line is \"0000\")\n assert_eq!(response, b\"0000\");\n }\n\n #[tokio::test]\n async fn test_ssh_git_handler_receive_pack_with_existing_repo() {\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let handler = SshGitHandler::new(storage.clone());\n\n // Create a repo first\n let repo_id = RepoId::new();\n storage.create(repo_id).await.unwrap();\n\n // Now receive_pack should work and return a valid response\n let result = handler.receive_pack(repo_id, vec![]).await;\n assert!(result.is_ok());\n let response = result.unwrap();\n // Response is a pkt-line acknowledgment\n assert_eq!(response, b\"0000\");\n }\n\n #[test]\n fn test_parse_ssh_command_with_leading_whitespace() {\n // Command with leading whitespace\n assert_eq!(\n parse_ssh_command(\" git-upload-pack /repo\"),\n Some((\"git-upload-pack\", \"/repo\"))\n );\n }\n\n #[test]\n fn test_parse_ssh_command_multiple_words_after_repo() {\n // Command with extra args after repo path\n assert_eq!(\n parse_ssh_command(\"git-upload-pack /repo.git extra args\"),\n Some((\"git-upload-pack\", \"/repo.git\"))\n );\n }\n}\n","traces":[{"line":17,"address":[5391456],"length":1,"stats":{"Line":1}},{"line":19,"address":[],"length":0,"stats":{"Line":5}},{"line":26,"address":[5387522,5384694,5384575,5384861,5384473,5385081,5384884,5387375,5384528,5387352,5387463,5387477],"length":1,"stats":{"Line":6}},{"line":31,"address":[5554062],"length":1,"stats":{"Line":2}},{"line":33,"address":[],"length":0,"stats":{"Line":3}},{"line":34,"address":[],"length":0,"stats":{"Line":0}},{"line":35,"address":[],"length":0,"stats":{"Line":0}},{"line":36,"address":[],"length":0,"stats":{"Line":0}},{"line":41,"address":[],"length":0,"stats":{"Line":1}},{"line":42,"address":[],"length":0,"stats":{"Line":1}},{"line":45,"address":[5554332,5554367],"length":1,"stats":{"Line":6}},{"line":50,"address":[],"length":0,"stats":{"Line":2}},{"line":52,"address":[],"length":0,"stats":{"Line":3}},{"line":53,"address":[],"length":0,"stats":{"Line":0}},{"line":54,"address":[],"length":0,"stats":{"Line":0}},{"line":55,"address":[],"length":0,"stats":{"Line":0}},{"line":59,"address":[],"length":0,"stats":{"Line":1}},{"line":60,"address":[],"length":0,"stats":{"Line":1}},{"line":65,"address":[5509408,5509815,5509809],"length":1,"stats":{"Line":4}},{"line":66,"address":[5509451],"length":1,"stats":{"Line":4}},{"line":67,"address":[5509804,5509561,5509481,5509543],"length":1,"stats":{"Line":12}},{"line":68,"address":[5509563,5509620],"length":1,"stats":{"Line":4}},{"line":69,"address":[5509653],"length":1,"stats":{"Line":2}},{"line":70,"address":[5509725],"length":1,"stats":{"Line":5}},{"line":72,"address":[5509554],"length":1,"stats":{"Line":2}}],"covered":19,"coverable":25},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","hooks.rs"],"content":"//! Git hook execution system\n\nuse async_trait::async_trait;\nuse gitforce_common::{RepoId, Result, UserId};\nuse serde::{Deserialize, Serialize};\n\n/// Hook executor trait\n#[async_trait]\npub trait HookExecutor: Send + Sync {\n /// Execute pre-receive hook\n async fn pre_receive(&self, payload: HookPayload) -> Result<()>;\n\n /// Execute post-receive hook\n async fn post_receive(&self, payload: HookPayload) -> Result<()>;\n}\n\n/// Hook payload for receive hooks\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct HookPayload {\n pub repo_id: RepoId,\n pub ref_name: String,\n pub old_hash: String,\n pub new_hash: String,\n pub pusher_id: Option,\n}\n\nimpl HookPayload {\n /// Create a new hook payload\n pub fn new(\n repo_id: RepoId,\n ref_name: String,\n old_hash: String,\n new_hash: String,\n pusher_id: Option,\n ) -> Self {\n Self {\n repo_id,\n ref_name,\n old_hash,\n new_hash,\n pusher_id,\n }\n }\n\n /// Get the branch name from the ref\n pub fn branch_name(&self) -> Option {\n if self.ref_name.starts_with(\"refs/heads/\") {\n Some(self.ref_name.strip_prefix(\"refs/heads/\").unwrap().to_string())\n } else {\n None\n }\n }\n\n /// Get the tag name from the ref\n pub fn tag_name(&self) -> Option {\n if self.ref_name.starts_with(\"refs/tags/\") {\n Some(self.ref_name.strip_prefix(\"refs/tags/\").unwrap().to_string())\n } else {\n None\n }\n }\n\n /// Check if this is a push to a branch\n pub fn is_branch_push(&self) -> bool {\n self.ref_name.starts_with(\"refs/heads/\")\n }\n\n /// Check if this is a tag push\n pub fn is_tag_push(&self) -> bool {\n self.ref_name.starts_with(\"refs/tags/\")\n }\n}\n\n/// Default hook executor that logs hooks\npub struct LoggingHookExecutor;\n\nimpl LoggingHookExecutor {\n pub fn new() -> Self {\n Self\n }\n}\n\nimpl Default for LoggingHookExecutor {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl HookExecutor for LoggingHookExecutor {\n async fn pre_receive(&self, payload: HookPayload) -> Result<()> {\n tracing::info!(\n \"pre-receive hook: repo={} ref={} {}->{} pusher={:?}\",\n payload.repo_id,\n payload.ref_name,\n payload.old_hash,\n payload.new_hash,\n payload.pusher_id\n );\n Ok(())\n }\n\n async fn post_receive(&self, payload: HookPayload) -> Result<()> {\n tracing::info!(\n \"post-receive hook: repo={} ref={} {}->{} pusher={:?}\",\n payload.repo_id,\n payload.ref_name,\n payload.old_hash,\n payload.new_hash,\n payload.pusher_id\n );\n Ok(())\n }\n}\n\n/// Hook manager for coordinating multiple hook executors\npub struct HookManager {\n executors: Vec>,\n}\n\nimpl HookManager {\n pub fn new() -> Self {\n Self {\n executors: Vec::new(),\n }\n }\n\n /// Add a hook executor\n pub fn add_executor(&mut self, executor: E) {\n self.executors.push(Box::new(executor));\n }\n\n /// Execute pre-receive hooks\n pub async fn pre_receive(&self, payload: HookPayload) -> Result<()> {\n for executor in &self.executors {\n executor.pre_receive(payload.clone()).await?;\n }\n Ok(())\n }\n\n /// Execute post-receive hooks\n pub async fn post_receive(&self, payload: HookPayload) -> Result<()> {\n for executor in &self.executors {\n executor.post_receive(payload.clone()).await?;\n }\n Ok(())\n }\n}\n\nimpl Default for HookManager {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_hook_payload() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n assert!(payload.is_branch_push());\n assert!(!payload.is_tag_push());\n assert_eq!(payload.branch_name(), Some(\"main\".to_string()));\n }\n\n #[test]\n fn test_hook_payload_tag() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/tags/v1.0\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n assert!(!payload.is_branch_push());\n assert!(payload.is_tag_push());\n assert_eq!(payload.tag_name(), Some(\"v1.0\".to_string()));\n }\n\n #[test]\n fn test_hook_payload_no_prefix() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/feature/test\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n assert!(payload.is_branch_push());\n assert_eq!(payload.branch_name(), Some(\"feature/test\".to_string()));\n }\n\n #[test]\n fn test_hook_payload_with_pusher() {\n let user_id = UserId::new();\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n Some(user_id),\n );\n\n assert_eq!(payload.pusher_id, Some(user_id));\n }\n\n #[tokio::test]\n async fn test_logging_hook_executor_pre_receive() {\n let executor = LoggingHookExecutor::new();\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n let result = executor.pre_receive(payload).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_logging_hook_executor_post_receive() {\n let executor = LoggingHookExecutor::new();\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n let result = executor.post_receive(payload).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_hook_manager_pre_receive() {\n let mut manager = HookManager::new();\n manager.add_executor(LoggingHookExecutor::new());\n\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n let result = manager.pre_receive(payload).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_hook_manager_post_receive() {\n let mut manager = HookManager::new();\n manager.add_executor(LoggingHookExecutor::new());\n\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n let result = manager.post_receive(payload).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_hook_manager_multiple_executors() {\n let mut manager = HookManager::new();\n manager.add_executor(LoggingHookExecutor::new());\n manager.add_executor(LoggingHookExecutor::new());\n\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n\n let result = manager.pre_receive(payload).await;\n assert!(result.is_ok());\n }\n\n #[test]\n fn test_logging_hook_executor_new() {\n let executor = LoggingHookExecutor::new();\n assert!(matches!(executor, LoggingHookExecutor));\n }\n\n #[test]\n fn test_hook_manager_new() {\n let manager = HookManager::new();\n assert!(matches!(manager, HookManager { .. }));\n }\n\n #[test]\n fn test_hook_payload_branch_name_none_for_non_branch() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/tags/v1.0\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n assert_eq!(payload.branch_name(), None);\n }\n\n #[test]\n fn test_hook_payload_tag_name_none_for_non_tag() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n assert_eq!(payload.tag_name(), None);\n }\n\n #[test]\n fn test_hook_payload_debug() {\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n let debug_str = format!(\"{:?}\", payload);\n assert!(debug_str.contains(\"HookPayload\"));\n }\n\n #[tokio::test]\n async fn test_hook_manager_empty_executors() {\n let manager = HookManager::new();\n let payload = HookPayload::new(\n RepoId::new(),\n \"refs/heads/main\".to_string(),\n \"abc123\".to_string(),\n \"def456\".to_string(),\n None,\n );\n // Empty manager should still work\n let result = manager.pre_receive(payload).await;\n assert!(result.is_ok());\n }\n}\n","traces":[{"line":29,"address":[5320384],"length":1,"stats":{"Line":1}},{"line":46,"address":[10753088],"length":1,"stats":{"Line":1}},{"line":47,"address":[10753169,10753118],"length":1,"stats":{"Line":3}},{"line":48,"address":[10753175],"length":1,"stats":{"Line":2}},{"line":50,"address":[5320164],"length":1,"stats":{"Line":1}},{"line":55,"address":[10753488],"length":1,"stats":{"Line":1}},{"line":56,"address":[10753518,10753569],"length":1,"stats":{"Line":2}},{"line":57,"address":[10753575],"length":1,"stats":{"Line":1}},{"line":59,"address":[10753556],"length":1,"stats":{"Line":1}},{"line":64,"address":[10753328],"length":1,"stats":{"Line":1}},{"line":65,"address":[10753333],"length":1,"stats":{"Line":1}},{"line":69,"address":[10753280],"length":1,"stats":{"Line":1}},{"line":70,"address":[10753285],"length":1,"stats":{"Line":1}},{"line":84,"address":[10754640],"length":1,"stats":{"Line":0}},{"line":85,"address":[10754641],"length":1,"stats":{"Line":0}},{"line":91,"address":[5321679],"length":1,"stats":{"Line":7}},{"line":92,"address":[10778584,10779045,10778401],"length":1,"stats":{"Line":4}},{"line":100,"address":[10778960],"length":1,"stats":{"Line":2}},{"line":103,"address":[10781226,10780912,10781368,10783376,10781078,10781944,10780959,10781204],"length":1,"stats":{"Line":7}},{"line":104,"address":[10781973,10781329,10781512],"length":1,"stats":{"Line":4}},{"line":112,"address":[5485608],"length":1,"stats":{"Line":2}},{"line":122,"address":[10753024],"length":1,"stats":{"Line":1}},{"line":124,"address":[10753037],"length":1,"stats":{"Line":1}},{"line":129,"address":[],"length":0,"stats":{"Line":1}},{"line":130,"address":[5480349],"length":1,"stats":{"Line":1}},{"line":134,"address":[5479478,5479168,5479203,5480328,5479353,5480124],"length":1,"stats":{"Line":7}},{"line":135,"address":[5479985,5479336,5479437],"length":1,"stats":{"Line":5}},{"line":136,"address":[5480224,5479374,5479509,5479533,5480046],"length":1,"stats":{"Line":3}},{"line":138,"address":[10776353],"length":1,"stats":{"Line":1}},{"line":142,"address":[5319987,5319952],"length":1,"stats":{"Line":4}},{"line":143,"address":[10776894,10777470,10776776],"length":1,"stats":{"Line":3}},{"line":144,"address":[10777531,10776970,10777713,10776824,10776998],"length":1,"stats":{"Line":3}},{"line":146,"address":[10777585],"length":1,"stats":{"Line":1}},{"line":151,"address":[10754608],"length":1,"stats":{"Line":0}},{"line":152,"address":[10754616],"length":1,"stats":{"Line":0}}],"covered":31,"coverable":35},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","lib.rs"],"content":"//! GitForce Core Git Server\n//!\n//! Git protocol handling, repository management, and hooks.\n\npub mod git_protocol;\npub mod hooks;\npub mod repo;\npub mod storage;\n\npub use hooks::{HookExecutor, HookManager, HookPayload};\npub use repo::{GitRef, RepoService};\npub use storage::{FileStorageBackend, StorageBackend};\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","repo.rs"],"content":"//! Repository management service\n\nuse crate::storage::StorageBackend;\nuse gitforce_common::{Error, RepoId, Result, UserId};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// Git reference information\n#[derive(Debug, Clone)]\npub struct GitRef {\n pub name: String,\n pub hash: String,\n pub is_branch: bool,\n pub is_tag: bool,\n}\n\n/// Repository service for managing git repositories\npub struct RepoService {\n storage: Arc,\n /// In-memory repo metadata cache\n repos: Arc>>,\n}\n\n#[derive(Debug, Clone)]\npub struct RepoMetadata {\n pub id: RepoId,\n pub name: String,\n pub owner_id: UserId,\n pub git_path: String,\n}\n\n/// Errors for repository operations\n#[derive(Debug, thiserror::Error)]\npub enum RepoError {\n #[error(\"Repository not found: {0}\")]\n NotFound(RepoId),\n\n #[error(\"Repository already exists: {0}\")]\n AlreadyExists(String),\n\n #[error(\"Invalid repository name: {0}\")]\n InvalidName(String),\n\n #[error(\"Storage error: {0}\")]\n Storage(String),\n}\n\nimpl From for gitforce_common::Error {\n fn from(err: RepoError) -> Self {\n match err {\n RepoError::NotFound(id) => gitforce_common::Error::not_found(\"repository\", id),\n RepoError::AlreadyExists(name) => gitforce_common::Error::already_exists(\"repository\", name),\n RepoError::InvalidName(name) => gitforce_common::Error::invalid_input(format!(\"invalid repo name: {}\", name)),\n RepoError::Storage(msg) => gitforce_common::Error::storage(msg),\n }\n }\n}\n\nimpl RepoService {\n /// Create a new repository service\n pub fn new(storage: S) -> Self {\n Self {\n storage: Arc::new(storage),\n repos: Arc::new(RwLock::new(HashMap::new())),\n }\n }\n\n /// Create a new repository\n pub async fn create(\n &self,\n name: String,\n owner_id: UserId,\n ) -> Result {\n // Validate name\n if name.is_empty() || name.len() > 255 {\n return Err(RepoError::InvalidName(name).into());\n }\n\n if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {\n return Err(RepoError::InvalidName(name).into());\n }\n\n let repo_id = RepoId::new();\n let git_path = self.storage.repo_path(repo_id);\n\n // Create storage\n self.storage.create(repo_id).await?;\n\n let metadata = RepoMetadata {\n id: repo_id,\n name: name.clone(),\n owner_id,\n git_path: git_path.to_string_lossy().to_string(),\n };\n\n // Cache metadata\n {\n let mut repos = self.repos.write().await;\n repos.insert(repo_id, metadata.clone());\n }\n\n tracing::info!(\"created repository {} for owner {}\", name, owner_id);\n Ok(metadata)\n }\n\n /// Get repository metadata\n pub async fn get(&self, repo_id: RepoId) -> Result> {\n {\n let repos = self.repos.read().await;\n if let Some(meta) = repos.get(&repo_id) {\n return Ok(Some(meta.clone()));\n }\n }\n\n // Could load from database here if needed\n Ok(None)\n }\n\n /// Delete a repository\n pub async fn delete(&self, repo_id: RepoId) -> Result<()> {\n // Remove from cache\n {\n let mut repos = self.repos.write().await;\n repos.remove(&repo_id);\n }\n\n // Delete from storage\n self.storage.delete(repo_id).await?;\n\n tracing::info!(\"deleted repository {}\", repo_id);\n Ok(())\n }\n\n /// List all repositories\n pub async fn list(&self) -> Vec {\n let repos = self.repos.read().await;\n repos.values().cloned().collect()\n }\n\n /// List repositories by owner\n pub async fn list_by_owner(&self, owner_id: UserId) -> Vec {\n let repos = self.repos.read().await;\n repos\n .values()\n .filter(|r| r.owner_id == owner_id)\n .cloned()\n .collect()\n }\n\n /// Get repository git path\n pub async fn get_git_path(&self, repo_id: RepoId) -> Result {\n let meta = self.get(repo_id).await?;\n match meta {\n Some(m) => Ok(m.git_path),\n None => Err(RepoError::NotFound(repo_id).into()),\n }\n }\n\n /// List references (branches and tags) for a repository\n pub async fn list_refs(&self, repo_id: RepoId) -> Result> {\n let repo = self.storage.open(repo_id).await?;\n let mut refs = Vec::new();\n\n // Iterate over all references\n for name in repo.references().map_err(|e| Error::git(format!(\"failed to list refs: {}\", e)))? {\n let name = name.map_err(|e| Error::git(format!(\"failed to read ref: {}\", e)))?;\n let ref_name = name.name().unwrap_or(\"\").to_string();\n let is_branch = ref_name.starts_with(\"refs/heads/\");\n let is_tag = ref_name.starts_with(\"refs/tags/\");\n\n if let Ok(hash) = name.peel_to_commit() {\n let hash_str = hash.id().to_string();\n\n refs.push(GitRef {\n name: ref_name,\n hash: hash_str,\n is_branch,\n is_tag,\n });\n }\n }\n\n Ok(refs)\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::storage::FileStorageBackend;\n use tempfile::tempdir;\n\n #[tokio::test]\n async fn test_repo_service() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n // Create repository\n let meta = service.create(\"test-repo\".to_string(), owner_id).await.unwrap();\n assert_eq!(meta.name, \"test-repo\");\n\n // Get repository\n let retrieved = service.get(meta.id).await.unwrap();\n assert!(retrieved.is_some());\n\n // List by owner\n let repos = service.list_by_owner(owner_id).await;\n assert_eq!(repos.len(), 1);\n\n // Delete repository\n service.delete(meta.id).await.unwrap();\n let repos = service.list_by_owner(owner_id).await;\n assert!(repos.is_empty());\n }\n\n #[test]\n fn test_git_ref_creation() {\n let git_ref = GitRef {\n name: \"refs/heads/main\".to_string(),\n hash: \"abc123\".to_string(),\n is_branch: true,\n is_tag: false,\n };\n assert_eq!(git_ref.name, \"refs/heads/main\");\n assert!(git_ref.is_branch);\n assert!(!git_ref.is_tag);\n }\n\n #[test]\n fn test_repo_metadata_creation() {\n let meta = RepoMetadata {\n id: RepoId::new(),\n name: \"test-repo\".to_string(),\n owner_id: UserId::new(),\n git_path: \"/git/repos/test\".to_string(),\n };\n assert_eq!(meta.name, \"test-repo\");\n }\n\n #[tokio::test]\n async fn test_repo_create_invalid_name() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n // Empty name should fail\n let result = service.create(\"\".to_string(), owner_id).await;\n assert!(result.is_err());\n\n // Name with invalid chars should fail\n let result = service.create(\"invalid name!\".to_string(), owner_id).await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_repo_get_not_found() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let result = service.get(RepoId::new()).await.unwrap();\n assert!(result.is_none());\n }\n\n #[tokio::test]\n async fn test_repo_list() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n service.create(\"repo1\".to_string(), owner_id).await.unwrap();\n service.create(\"repo2\".to_string(), owner_id).await.unwrap();\n\n let repos = service.list().await;\n assert_eq!(repos.len(), 2);\n }\n\n #[tokio::test]\n async fn test_repo_git_path() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n let meta = service.create(\"test-repo\".to_string(), owner_id).await.unwrap();\n let path = service.get_git_path(meta.id).await.unwrap();\n assert!(!path.is_empty());\n\n // Non-existent repo should fail\n let result = service.get_git_path(RepoId::new()).await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_repo_list_by_owner() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner1 = UserId::new();\n let owner2 = UserId::new();\n\n service.create(\"repo1\".to_string(), owner1).await.unwrap();\n service.create(\"repo2\".to_string(), owner1).await.unwrap();\n service.create(\"repo3\".to_string(), owner2).await.unwrap();\n\n let owner1_repos = service.list_by_owner(owner1).await;\n assert_eq!(owner1_repos.len(), 2);\n\n let owner2_repos = service.list_by_owner(owner2).await;\n assert_eq!(owner2_repos.len(), 1);\n }\n\n #[test]\n fn test_repo_error_display() {\n let repo_id = RepoId::new();\n let err = RepoError::NotFound(repo_id);\n assert!(format!(\"{}\", err).contains(\"not found\"));\n\n let err = RepoError::AlreadyExists(\"test\".to_string());\n assert!(format!(\"{}\", err).contains(\"already exists\"));\n\n let err = RepoError::InvalidName(\"bad\".to_string());\n assert!(format!(\"{}\", err).contains(\"Invalid repository name\"));\n\n let err = RepoError::Storage(\"disk error\".to_string());\n assert!(format!(\"{}\", err).contains(\"Storage error\"));\n }\n\n #[tokio::test]\n async fn test_repo_list_refs_empty() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n let meta = service.create(\"test-repo\".to_string(), owner_id).await.unwrap();\n\n // Fresh repo has no refs\n let refs = service.list_refs(meta.id).await.unwrap();\n assert!(refs.is_empty());\n }\n\n #[tokio::test]\n async fn test_repo_git_ref_fields() {\n let git_ref = GitRef {\n name: \"refs/tags/v1.0\".to_string(),\n hash: \"abc123\".to_string(),\n is_branch: false,\n is_tag: true,\n };\n assert!(!git_ref.is_branch);\n assert!(git_ref.is_tag);\n assert_eq!(git_ref.name, \"refs/tags/v1.0\");\n }\n\n #[tokio::test]\n async fn test_repo_create_invalid_name_too_long() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n let long_name = \"a\".repeat(256);\n\n let result = service.create(long_name, owner_id).await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_repo_create_invalid_chars() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n // Test various invalid characters\n for name in [\"has!space\", \"has@at\", \"has#hash\", \"has$dollar\", \"has%percent\"] {\n let result = service.create(name.to_string(), owner_id).await;\n assert!(result.is_err(), \"Expected '{}' to be invalid\", name);\n }\n }\n\n #[tokio::test]\n async fn test_repo_create_valid_special_chars() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let owner_id = UserId::new();\n\n // Valid names with dashes, underscores, dots\n for name in [\"my-repo\", \"my_repo\", \"my.repo\", \"repo.1\", \"repo-2_name\"] {\n let result = service.create(name.to_string(), owner_id).await;\n assert!(result.is_ok(), \"Expected '{}' to be valid\", name);\n }\n }\n\n #[tokio::test]\n async fn test_repo_delete_nonexistent() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n // Deleting non-existent repo should not panic\n let result = service.delete(RepoId::new()).await;\n assert!(result.is_ok()); // delete is idempotent\n }\n\n #[tokio::test]\n async fn test_repo_list_by_owner_empty() {\n let dir = tempdir().unwrap();\n let storage = FileStorageBackend::new(dir.path());\n let service = RepoService::new(storage);\n\n let repos = service.list_by_owner(UserId::new()).await;\n assert!(repos.is_empty());\n }\n\n #[test]\n fn test_git_ref_debug() {\n let git_ref = GitRef {\n name: \"refs/heads/main\".to_string(),\n hash: \"abc123def456\".to_string(),\n is_branch: true,\n is_tag: false,\n };\n let debug_str = format!(\"{:?}\", git_ref);\n assert!(debug_str.contains(\"main\"));\n assert!(debug_str.contains(\"abc123\"));\n }\n\n #[test]\n fn test_repo_metadata_debug() {\n let meta = RepoMetadata {\n id: RepoId::new(),\n name: \"debug-test\".to_string(),\n owner_id: UserId::new(),\n git_path: \"/tmp/git\".to_string(),\n };\n let debug_str = format!(\"{:?}\", meta);\n assert!(debug_str.contains(\"debug-test\"));\n }\n\n #[test]\n fn test_repo_error_not_found_display() {\n let repo_id = RepoId::new();\n let err = RepoError::NotFound(repo_id);\n let display = format!(\"{}\", err);\n assert!(display.contains(\"Repository not found\"));\n }\n\n #[test]\n fn test_repo_error_already_exists_display() {\n let err = RepoError::AlreadyExists(\"my-repo\".to_string());\n let display = format!(\"{}\", err);\n assert!(display.contains(\"already exists\"));\n }\n\n #[test]\n fn test_repo_error_invalid_name_display() {\n let err = RepoError::InvalidName(\"bad!name\".to_string());\n let display = format!(\"{}\", err);\n assert!(display.contains(\"Invalid repository name\"));\n }\n\n #[test]\n fn test_repo_error_storage_display() {\n let err = RepoError::Storage(\"disk full\".to_string());\n let display = format!(\"{}\", err);\n assert!(display.contains(\"Storage error\"));\n }\n}\n","traces":[{"line":50,"address":[5468272,5468874],"length":1,"stats":{"Line":1}},{"line":51,"address":[5468294],"length":1,"stats":{"Line":1}},{"line":52,"address":[5468335],"length":1,"stats":{"Line":1}},{"line":53,"address":[5468419],"length":1,"stats":{"Line":0}},{"line":54,"address":[5468693,5468520],"length":1,"stats":{"Line":1}},{"line":55,"address":[5468583],"length":1,"stats":{"Line":0}},{"line":62,"address":[],"length":0,"stats":{"Line":1}},{"line":64,"address":[],"length":0,"stats":{"Line":1}},{"line":65,"address":[],"length":0,"stats":{"Line":3}},{"line":70,"address":[5521392],"length":1,"stats":{"Line":2}},{"line":76,"address":[],"length":0,"stats":{"Line":5}},{"line":77,"address":[5521786,5522568],"length":1,"stats":{"Line":2}},{"line":80,"address":[],"length":0,"stats":{"Line":6}},{"line":81,"address":[5522179,5522055],"length":1,"stats":{"Line":2}},{"line":84,"address":[],"length":0,"stats":{"Line":1}},{"line":85,"address":[],"length":0,"stats":{"Line":1}},{"line":88,"address":[5521656,5522418,5522335,5522655],"length":1,"stats":{"Line":5}},{"line":92,"address":[],"length":0,"stats":{"Line":3}},{"line":94,"address":[],"length":0,"stats":{"Line":6}},{"line":99,"address":[],"length":0,"stats":{"Line":3}},{"line":100,"address":[],"length":0,"stats":{"Line":6}},{"line":103,"address":[],"length":0,"stats":{"Line":6}},{"line":104,"address":[],"length":0,"stats":{"Line":3}},{"line":108,"address":[],"length":0,"stats":{"Line":4}},{"line":110,"address":[5548967],"length":1,"stats":{"Line":2}},{"line":111,"address":[],"length":0,"stats":{"Line":3}},{"line":112,"address":[],"length":0,"stats":{"Line":2}},{"line":117,"address":[],"length":0,"stats":{"Line":1}},{"line":121,"address":[],"length":0,"stats":{"Line":5}},{"line":124,"address":[5549617],"length":1,"stats":{"Line":2}},{"line":125,"address":[],"length":0,"stats":{"Line":2}},{"line":129,"address":[5527519,5529380,5527329,5526766],"length":1,"stats":{"Line":2}},{"line":131,"address":[],"length":0,"stats":{"Line":2}},{"line":132,"address":[],"length":0,"stats":{"Line":1}},{"line":136,"address":[],"length":0,"stats":{"Line":4}},{"line":137,"address":[],"length":0,"stats":{"Line":2}},{"line":138,"address":[],"length":0,"stats":{"Line":2}},{"line":142,"address":[],"length":0,"stats":{"Line":4}},{"line":143,"address":[5550599],"length":1,"stats":{"Line":2}},{"line":144,"address":[],"length":0,"stats":{"Line":1}},{"line":146,"address":[],"length":0,"stats":{"Line":4}},{"line":152,"address":[],"length":0,"stats":{"Line":4}},{"line":153,"address":[5517487,5517324,5517402,5517262],"length":1,"stats":{"Line":2}},{"line":154,"address":[],"length":0,"stats":{"Line":1}},{"line":155,"address":[],"length":0,"stats":{"Line":1}},{"line":156,"address":[],"length":0,"stats":{"Line":2}},{"line":161,"address":[],"length":0,"stats":{"Line":4}},{"line":162,"address":[],"length":0,"stats":{"Line":2}},{"line":163,"address":[],"length":0,"stats":{"Line":1}},{"line":166,"address":[],"length":0,"stats":{"Line":3}},{"line":167,"address":[],"length":0,"stats":{"Line":0}},{"line":168,"address":[],"length":0,"stats":{"Line":0}},{"line":169,"address":[],"length":0,"stats":{"Line":0}},{"line":170,"address":[5531900],"length":1,"stats":{"Line":0}},{"line":172,"address":[],"length":0,"stats":{"Line":0}},{"line":173,"address":[5532191,5532104],"length":1,"stats":{"Line":0}},{"line":175,"address":[],"length":0,"stats":{"Line":0}},{"line":176,"address":[],"length":0,"stats":{"Line":0}},{"line":177,"address":[],"length":0,"stats":{"Line":0}},{"line":178,"address":[],"length":0,"stats":{"Line":0}},{"line":179,"address":[],"length":0,"stats":{"Line":0}},{"line":184,"address":[],"length":0,"stats":{"Line":1}}],"covered":49,"coverable":62},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-core","src","storage.rs"],"content":"//! Filesystem-based git storage backend\n\nuse async_trait::async_trait;\nuse gitforce_common::{Error, RepoId, Result};\nuse std::path::{Path, PathBuf};\nuse tokio::fs;\n\n/// Storage backend trait for git repositories\n#[async_trait]\npub trait StorageBackend: Send + Sync {\n /// Get the path for a repository\n fn repo_path(&self, repo_id: RepoId) -> PathBuf;\n\n /// Check if a repository exists\n async fn exists(&self, repo_id: RepoId) -> bool;\n\n /// Create a new bare repository\n async fn create(&self, repo_id: RepoId) -> Result;\n\n /// Delete a repository\n async fn delete(&self, repo_id: RepoId) -> Result<()>;\n\n /// Initialize a bare git repository\n async fn init_bare(&self, path: &Path) -> Result<()>;\n\n /// Open an existing repository\n async fn open(&self, repo_id: RepoId) -> Result;\n}\n\n/// Filesystem-based storage implementation\n#[derive(Clone)]\npub struct FileStorageBackend {\n root: PathBuf,\n}\n\nimpl FileStorageBackend {\n /// Create a new filesystem storage backend\n pub fn new(root: impl Into) -> Self {\n Self { root: root.into() }\n }\n\n /// Get the root directory\n pub fn root(&self) -> &Path {\n &self.root\n }\n\n /// Ensure the root directory exists\n pub async fn ensure_root(&self) -> Result<()> {\n tokio::fs::create_dir_all(&self.root).await.map_err(|e| {\n Error::storage(format!(\"failed to create storage root: {}\", e))\n })?;\n Ok(())\n }\n}\n\n#[async_trait]\nimpl StorageBackend for FileStorageBackend {\n fn repo_path(&self, repo_id: RepoId) -> PathBuf {\n self.root.join(repo_id.to_string())\n }\n\n async fn exists(&self, repo_id: RepoId) -> bool {\n let path = self.repo_path(repo_id);\n fs::metadata(&path).await.is_ok()\n }\n\n async fn create(&self, repo_id: RepoId) -> Result {\n let path = self.repo_path(repo_id);\n\n // Create repository directory\n tokio::fs::create_dir_all(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to create repo directory: {}\", e))\n })?;\n\n // Initialize bare git repository\n self.init_bare(&path).await?;\n\n tracing::info!(\"created repository at {:?}\", path);\n Ok(path)\n }\n\n async fn delete(&self, repo_id: RepoId) -> Result<()> {\n let path = self.repo_path(repo_id);\n\n if path.exists() {\n tokio::fs::remove_dir_all(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to delete repository: {}\", e))\n })?;\n tracing::info!(\"deleted repository at {:?}\", path);\n }\n\n Ok(())\n }\n\n async fn init_bare(&self, path: &Path) -> Result<()> {\n // Use git2 to create a bare repository\n let repo = git2::Repository::init_bare(path).map_err(|e| {\n Error::git(format!(\"failed to initialize bare repository: {}\", e))\n })?;\n\n // Configure repository for optimal git server usage\n let mut config = repo.config().map_err(|e| {\n Error::git(format!(\"failed to get repository config: {}\", e))\n })?;\n\n // Disable garbage collection for server-side repos\n config.set_bool(\"gc.autodetach\", false).ok();\n config.set_bool(\"gc.auto\", false).ok();\n\n tracing::debug!(\"initialized bare git repository at {:?}\", path);\n Ok(())\n }\n\n async fn open(&self, repo_id: RepoId) -> Result {\n let path = self.repo_path(repo_id);\n\n let repo = git2::Repository::open(&path).map_err(|e| {\n Error::git(format!(\"failed to open repository at {:?}: {}\", path, e))\n })?;\n\n Ok(repo)\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::tempdir;\n\n #[tokio::test]\n async fn test_file_storage_backend() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n\n let repo_id = RepoId::new();\n\n // Create repository\n let path = backend.create(repo_id).await.unwrap();\n assert!(path.exists());\n\n // Check exists\n assert!(backend.exists(repo_id).await);\n\n // Open repository\n let repo = backend.open(repo_id).await.unwrap();\n assert!(repo.is_bare());\n\n // Delete repository\n backend.delete(repo_id).await.unwrap();\n assert!(!backend.exists(repo_id).await);\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_repo_path() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n let repo_id = RepoId::new();\n\n let path = backend.repo_path(repo_id);\n assert!(path.to_str().unwrap().ends_with(&repo_id.to_string()));\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_root() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n assert_eq!(backend.root(), dir.path());\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_ensure_root() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path().join(\"nonexistent\"));\n backend.ensure_root().await.unwrap();\n assert!(dir.path().join(\"nonexistent\").exists());\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_delete_nonexistent() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n let repo_id = RepoId::new();\n\n // Delete should not fail if repo doesn't exist\n backend.delete(repo_id).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_exists_false() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n let repo_id = RepoId::new();\n\n assert!(!backend.exists(repo_id).await);\n }\n\n #[tokio::test]\n async fn test_file_storage_open_nonexistent() {\n let dir = tempdir().unwrap();\n let backend = FileStorageBackend::new(dir.path());\n let repo_id = RepoId::new();\n\n let result = backend.open(repo_id).await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_file_storage_backend_clone() {\n let dir = tempdir().unwrap();\n let backend1 = FileStorageBackend::new(dir.path());\n let backend2 = backend1.clone();\n\n let repo_id = RepoId::new();\n let path1 = backend1.repo_path(repo_id);\n let path2 = backend2.repo_path(repo_id);\n\n assert_eq!(path1, path2);\n }\n\n #[test]\n fn test_file_storage_backend_new() {\n let backend = FileStorageBackend::new(\"/tmp/test\");\n assert_eq!(backend.root().to_str().unwrap(), \"/tmp/test\");\n }\n}\n","traces":[{"line":38,"address":[6480720],"length":1,"stats":{"Line":7}},{"line":39,"address":[],"length":0,"stats":{"Line":7}},{"line":43,"address":[10759312],"length":1,"stats":{"Line":1}},{"line":44,"address":[10759317],"length":1,"stats":{"Line":1}},{"line":48,"address":[10759304,10759296],"length":1,"stats":{"Line":4}},{"line":49,"address":[6454838],"length":1,"stats":{"Line":3}},{"line":50,"address":[10773932,10773867],"length":1,"stats":{"Line":0}},{"line":52,"address":[10773765],"length":1,"stats":{"Line":1}},{"line":58,"address":[10759184],"length":1,"stats":{"Line":2}},{"line":59,"address":[10759221],"length":1,"stats":{"Line":4}},{"line":62,"address":[10768878,10769169,10768816,10768946,10768745,10768981,10769626,10768720,10768885],"length":1,"stats":{"Line":6}},{"line":63,"address":[10768905],"length":1,"stats":{"Line":1}},{"line":64,"address":[10745076],"length":1,"stats":{"Line":6}},{"line":67,"address":[10758943],"length":1,"stats":{"Line":22}},{"line":68,"address":[10761863],"length":1,"stats":{"Line":2}},{"line":71,"address":[5270356,5270797,5270619,5274011,5274017,5270699,5271145,5273792,5271418],"length":1,"stats":{"Line":9}},{"line":72,"address":[10764907,10764972],"length":1,"stats":{"Line":0}},{"line":76,"address":[10744709],"length":1,"stats":{"Line":1}},{"line":78,"address":[10763385,10763937],"length":1,"stats":{"Line":10}},{"line":79,"address":[10763797],"length":1,"stats":{"Line":4}},{"line":82,"address":[5274032,5274079,5274353,5274182,5274720,5274287,5276546,5276577,5274407],"length":1,"stats":{"Line":6}},{"line":83,"address":[10765782],"length":1,"stats":{"Line":1}},{"line":85,"address":[10766018,10765930],"length":1,"stats":{"Line":2}},{"line":86,"address":[10765684,10768319,10766097,10766610,10768325,10766242,10768096],"length":1,"stats":{"Line":2}},{"line":87,"address":[5276987,5277052],"length":1,"stats":{"Line":0}},{"line":89,"address":[10766695],"length":1,"stats":{"Line":1}},{"line":92,"address":[5274587],"length":1,"stats":{"Line":1}},{"line":95,"address":[10759139],"length":1,"stats":{"Line":16}},{"line":97,"address":[10772832,10769874,10772192,10770002,10773050,10770145],"length":1,"stats":{"Line":13}},{"line":98,"address":[5281270,5281331],"length":1,"stats":{"Line":0}},{"line":102,"address":[10770254,10772592,10770325,10770468,10772810],"length":1,"stats":{"Line":2}},{"line":103,"address":[10772614,10772679],"length":1,"stats":{"Line":0}},{"line":107,"address":[10770577,10770663],"length":1,"stats":{"Line":2}},{"line":108,"address":[10770682],"length":1,"stats":{"Line":1}},{"line":110,"address":[10770743,10771191],"length":1,"stats":{"Line":8}},{"line":111,"address":[5279566],"length":1,"stats":{"Line":1}},{"line":114,"address":[10760501,10760731,10760774,10761228,10760464,10760589,10760660,10761201],"length":1,"stats":{"Line":10}},{"line":115,"address":[10760683],"length":1,"stats":{"Line":4}},{"line":117,"address":[10760813,10760881,10761032,10761541,10761264],"length":1,"stats":{"Line":11}},{"line":118,"address":[10761294,10761375],"length":1,"stats":{"Line":3}},{"line":121,"address":[10761130],"length":1,"stats":{"Line":3}}],"covered":36,"coverable":41},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","connection.rs"],"content":"//! Database connection pool management with SQLite for MVP\n\nuse gitforce_common::{Error, Result};\nuse sqlx::sqlite::{SqlitePool, SqlitePoolOptions};\nuse std::path::Path;\n\n/// SQLite connection pool wrapper\n#[derive(Clone)]\npub struct Pool {\n pool: SqlitePool,\n}\n\nimpl Pool {\n /// Create a new connection pool from a database URL or file path\n pub async fn new(database_url: &str) -> Result {\n // Handle file paths - SQLite uses file: prefix or bare paths\n let connect_url = if database_url.starts_with(\"sqlite:\") || database_url.starts_with(\"file:\") {\n database_url.to_string()\n } else if Path::new(database_url).exists() || database_url.contains('/') {\n format!(\"sqlite:{}?mode=rwc\", database_url)\n } else {\n // Memory database\n \"sqlite::memory:\".to_string()\n };\n\n let pool = SqlitePoolOptions::new()\n .max_connections(5)\n .connect(&connect_url)\n .await\n .map_err(|e| Error::database(format!(\"failed to connect to database: {}\", e)))?;\n\n Ok(Self { pool })\n }\n\n /// Create an in-memory pool for testing\n pub async fn memory() -> Result {\n Self::new(\"sqlite::memory:\").await\n }\n\n /// Get the underlying pool\n pub fn pool(&self) -> &SqlitePool {\n &self.pool\n }\n\n /// Run migrations to create tables\n pub async fn migrate(&self) -> Result<()> {\n // Create users table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY,\n username TEXT NOT NULL UNIQUE,\n email TEXT NOT NULL,\n password_hash TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create users table: {}\", e)))?;\n\n // Create repositories table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS repositories (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n owner_id TEXT NOT NULL,\n visibility TEXT NOT NULL DEFAULT 'private',\n git_path TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (owner_id) REFERENCES users(id)\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create repositories table: {}\", e)))?;\n\n // Create pipelines table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS pipelines (\n id TEXT PRIMARY KEY,\n repo_id TEXT NOT NULL,\n name TEXT NOT NULL,\n trigger_type TEXT NOT NULL,\n config TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (repo_id) REFERENCES repositories(id)\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create pipelines table: {}\", e)))?;\n\n // Create pipeline_runs table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS pipeline_runs (\n id TEXT PRIMARY KEY,\n pipeline_id TEXT NOT NULL,\n repo_id TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n triggered_by TEXT NOT NULL,\n commit_hash TEXT NOT NULL,\n started_at TEXT,\n finished_at TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (pipeline_id) REFERENCES pipelines(id),\n FOREIGN KEY (repo_id) REFERENCES repositories(id)\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create pipeline_runs table: {}\", e)))?;\n\n // Create runners table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS runners (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n runner_type TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'offline',\n capacity INTEGER NOT NULL DEFAULT 1,\n labels TEXT NOT NULL DEFAULT '[]',\n last_heartbeat TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create runners table: {}\", e)))?;\n\n // Create jobs table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n pipeline_run_id TEXT NOT NULL,\n name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n runner_id TEXT,\n started_at TEXT,\n finished_at TEXT,\n retry_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n FOREIGN KEY (pipeline_run_id) REFERENCES pipeline_runs(id),\n FOREIGN KEY (runner_id) REFERENCES runners(id)\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create jobs table: {}\", e)))?;\n\n // Create artifacts table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS artifacts (\n id TEXT PRIMARY KEY,\n job_id TEXT NOT NULL,\n name TEXT NOT NULL,\n path TEXT NOT NULL,\n checksum TEXT NOT NULL,\n size_bytes INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (job_id) REFERENCES jobs(id)\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create artifacts table: {}\", e)))?;\n\n // Create events table\n sqlx::query(\n r#\"\n CREATE TABLE IF NOT EXISTS events (\n id TEXT PRIMARY KEY,\n event_type TEXT NOT NULL,\n payload TEXT NOT NULL,\n created_at TEXT NOT NULL\n )\n \"#,\n )\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"failed to create events table: {}\", e)))?;\n\n tracing::info!(\"database migrations completed successfully\");\n Ok(())\n }\n\n /// Check database health\n pub async fn health_check(&self) -> Result<()> {\n sqlx::query(\"SELECT 1\")\n .execute(&self.pool)\n .await\n .map_err(|e| Error::database(format!(\"health check failed: {}\", e)))?;\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_memory_pool_creation() {\n let pool = Pool::memory().await;\n assert!(pool.is_ok());\n }\n\n #[tokio::test]\n async fn test_migrations() {\n let pool = Pool::memory().await.unwrap();\n let result = pool.migrate().await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_health_check() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n let result = pool.health_check().await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_pool_new_with_memory_url() {\n // Test with explicit memory URL\n let pool = Pool::new(\"sqlite::memory:\").await;\n assert!(pool.is_ok());\n }\n\n #[test]\n fn test_pool_clone() {\n // Pool is Clone, verify it can be cloned\n // We can't clone without &self but we can verify the type is Clone\n fn assert_clone() {}\n assert_clone::();\n }\n}\n","traces":[{"line":15,"address":[16541868,16541910,16542694,16541712,16541762],"length":1,"stats":{"Line":96}},{"line":17,"address":[14593605,14593401,14593524],"length":1,"stats":{"Line":48}},{"line":18,"address":[12989655,12989992],"length":1,"stats":{"Line":50}},{"line":19,"address":[14090480,14090613],"length":1,"stats":{"Line":0}},{"line":20,"address":[24646306,24646403],"length":1,"stats":{"Line":0}},{"line":23,"address":[24646352],"length":1,"stats":{"Line":0}},{"line":26,"address":[12989869,12990596,12990533,12990787,12990671,12990266,12990146],"length":1,"stats":{"Line":107}},{"line":28,"address":[21165129,21164810,21164890,21164911,21164970],"length":1,"stats":{"Line":50}},{"line":29,"address":[12989543,12990293,12990379,12990189,12990581],"length":1,"stats":{"Line":69}},{"line":30,"address":[21165846,21165555,21165824,21165460],"length":1,"stats":{"Line":13}},{"line":32,"address":[14091674],"length":1,"stats":{"Line":13}},{"line":36,"address":[10779123,10779120],"length":1,"stats":{"Line":102}},{"line":37,"address":[12991341,12991453,12991399,12991548],"length":1,"stats":{"Line":61}},{"line":41,"address":[10779104],"length":1,"stats":{"Line":12}},{"line":46,"address":[5990128,5990136],"length":1,"stats":{"Line":159}},{"line":59,"address":[24648738],"length":1,"stats":{"Line":17}},{"line":60,"address":[6324187,6324065,6324404,6323819,6324130],"length":1,"stats":{"Line":55}},{"line":61,"address":[21167475,21167570,21176064,21176086],"length":1,"stats":{"Line":7}},{"line":78,"address":[21167774],"length":1,"stats":{"Line":15}},{"line":79,"address":[5817093],"length":1,"stats":{"Line":55}},{"line":80,"address":[14604208,14597347,14597442,14604230],"length":1,"stats":{"Line":15}},{"line":96,"address":[6282246],"length":1,"stats":{"Line":15}},{"line":97,"address":[11512459],"length":1,"stats":{"Line":62}},{"line":98,"address":[6287526,6282650,6287504,6282721],"length":1,"stats":{"Line":16}},{"line":118,"address":[6326245],"length":1,"stats":{"Line":21}},{"line":119,"address":[10862785],"length":1,"stats":{"Line":58}},{"line":120,"address":[10657664,10657569,10662160,10662182],"length":1,"stats":{"Line":8}},{"line":138,"address":[14599068],"length":1,"stats":{"Line":15}},{"line":139,"address":[10862807],"length":1,"stats":{"Line":63}},{"line":140,"address":[16553142,16548007,16553120,16547912],"length":1,"stats":{"Line":21}},{"line":160,"address":[6284271],"length":1,"stats":{"Line":14}},{"line":161,"address":[6284359,6280708,6284314,6284600,6284391],"length":1,"stats":{"Line":61}},{"line":162,"address":[6287766,6287744,6284675,6284746],"length":1,"stats":{"Line":14}},{"line":179,"address":[14600490],"length":1,"stats":{"Line":12}},{"line":180,"address":[5817203],"length":1,"stats":{"Line":65}},{"line":181,"address":[21171798,21175846,21175824,21171893],"length":1,"stats":{"Line":11}},{"line":194,"address":[10660001],"length":1,"stats":{"Line":21}},{"line":195,"address":[14601297,14601232,14601333,14596078,14601550],"length":1,"stats":{"Line":70}},{"line":196,"address":[6329501,6329596,6331030,6331008],"length":1,"stats":{"Line":14}},{"line":198,"address":[24654465,24654896],"length":1,"stats":{"Line":35}},{"line":199,"address":[14602274],"length":1,"stats":{"Line":13}},{"line":203,"address":[14089161,14089893,14089027,14089310,14089119,14088992],"length":1,"stats":{"Line":12}},{"line":204,"address":[24645219,24644816,24645354,24645282,24645448,24644992,24645619],"length":1,"stats":{"Line":15}},{"line":205,"address":[21163237],"length":1,"stats":{"Line":3}},{"line":206,"address":[12988557,12988362,12988755,12988507,12988448],"length":1,"stats":{"Line":12}},{"line":207,"address":[16541494,16541249,16541472,16541170],"length":1,"stats":{"Line":3}},{"line":208,"address":[6320852],"length":1,"stats":{"Line":3}}],"covered":44,"coverable":47},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","lib.rs"],"content":"//! GitForce Database Library\n//!\n//! Database models and queries for GitForce entities.\n\npub mod connection;\npub mod models;\npub mod queries;\n\npub use connection::Pool;\npub use models::*;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","artifact.rs"],"content":"//! Artifact model\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::JobId;\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Artifact entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Artifact {\n pub id: Uuid,\n pub job_id: JobId,\n pub path: String,\n pub checksum: String,\n pub size_bytes: i64,\n pub created_at: DateTime,\n}\n\nimpl Artifact {\n /// Create a new artifact\n pub fn new(job_id: JobId, path: String, checksum: String, size_bytes: i64) -> Self {\n Self {\n id: Uuid::new_v4(),\n job_id,\n path,\n checksum,\n size_bytes,\n created_at: Utc::now(),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_artifact_creation() {\n let artifact = Artifact::new(\n JobId::new(),\n \"/path/to/test.bin\".to_string(),\n \"abc123\".to_string(),\n 1024,\n );\n assert_eq!(artifact.path, \"/path/to/test.bin\");\n assert_eq!(artifact.size_bytes, 1024);\n assert_eq!(artifact.checksum, \"abc123\");\n }\n\n #[test]\n fn test_artifact_id_unique() {\n let artifact1 = Artifact::new(JobId::new(), \"/path/1\".to_string(), \"abc\".to_string(), 100);\n let artifact2 = Artifact::new(JobId::new(), \"/path/2\".to_string(), \"def\".to_string(), 200);\n assert_ne!(artifact1.id, artifact2.id);\n }\n\n #[test]\n fn test_artifact_size() {\n let artifact = Artifact::new(\n JobId::new(),\n \"/path/to/artifact.zip\".to_string(),\n \"checksum123\".to_string(),\n 4096,\n );\n assert_eq!(artifact.size_bytes, 4096);\n }\n}\n","traces":[{"line":21,"address":[6270824,6270432,6270877],"length":1,"stats":{"Line":3}},{"line":23,"address":[6270476],"length":1,"stats":{"Line":3}},{"line":28,"address":[6270625],"length":1,"stats":{"Line":3}}],"covered":3,"coverable":3},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","event.rs"],"content":"//! Event model\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Event entity (append-only log)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Event {\n pub id: Uuid,\n pub event_type: String,\n pub payload: serde_json::Value,\n pub created_at: DateTime,\n}\n\nimpl Event {\n /// Create a new event\n pub fn new(event_type: String, payload: serde_json::Value) -> Self {\n Self {\n id: Uuid::new_v4(),\n event_type,\n payload,\n created_at: Utc::now(),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_event_creation() {\n let event = Event::new(\n \"push\".to_string(),\n serde_json::json!({\"ref\": \"refs/heads/main\"}),\n );\n assert_eq!(event.event_type, \"push\");\n }\n\n #[test]\n fn test_event_id_unique() {\n let event1 = Event::new(\"push\".to_string(), serde_json::json!({}));\n let event2 = Event::new(\"push\".to_string(), serde_json::json!({}));\n assert_ne!(event1.id, event2.id);\n }\n\n #[test]\n fn test_event_payload() {\n let payload = serde_json::json!({\"key\": \"value\", \"number\": 42});\n let event = Event::new(\"test\".to_string(), payload.clone());\n assert_eq!(event.payload, payload);\n }\n}\n","traces":[{"line":18,"address":[6428960,6428908,6428560],"length":1,"stats":{"Line":2}},{"line":20,"address":[6428586],"length":1,"stats":{"Line":2}},{"line":23,"address":[6428734],"length":1,"stats":{"Line":3}}],"covered":3,"coverable":3},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","job.rs"],"content":"//! Job model\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::{JobId, PipelineRunId, RunnerId};\nuse serde::{Deserialize, Serialize};\n\n/// Job status\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum JobStatus {\n Pending,\n Queued,\n Assigned,\n Running,\n Succeeded,\n Failed,\n Cancelled,\n TimedOut,\n}\n\nimpl JobStatus {\n pub fn as_str(&self) -> &'static str {\n match self {\n JobStatus::Pending => \"pending\",\n JobStatus::Queued => \"queued\",\n JobStatus::Assigned => \"assigned\",\n JobStatus::Running => \"running\",\n JobStatus::Succeeded => \"succeeded\",\n JobStatus::Failed => \"failed\",\n JobStatus::Cancelled => \"cancelled\",\n JobStatus::TimedOut => \"timed_out\",\n }\n }\n\n #[allow(clippy::should_implement_trait)]\n pub fn from_str(s: &str) -> Option {\n match s {\n \"pending\" => Some(JobStatus::Pending),\n \"queued\" => Some(JobStatus::Queued),\n \"assigned\" => Some(JobStatus::Assigned),\n \"running\" => Some(JobStatus::Running),\n \"succeeded\" => Some(JobStatus::Succeeded),\n \"failed\" => Some(JobStatus::Failed),\n \"cancelled\" => Some(JobStatus::Cancelled),\n \"timed_out\" => Some(JobStatus::TimedOut),\n _ => None,\n }\n }\n\n /// Check if job is in a terminal state\n pub fn is_terminal(&self) -> bool {\n matches!(\n self,\n JobStatus::Succeeded | JobStatus::Failed | JobStatus::Cancelled | JobStatus::TimedOut\n )\n }\n}\n\n/// Job entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Job {\n pub id: JobId,\n pub pipeline_run_id: PipelineRunId,\n pub name: String,\n pub status: String,\n pub runner_id: Option,\n pub started_at: Option>,\n pub finished_at: Option>,\n pub retry_count: i32,\n pub created_at: DateTime,\n}\n\nimpl Job {\n /// Create a new job\n pub fn new(pipeline_run_id: PipelineRunId, name: String) -> Self {\n Self {\n id: JobId::new(),\n pipeline_run_id,\n name,\n status: JobStatus::Pending.as_str().to_string(),\n runner_id: None,\n started_at: None,\n finished_at: None,\n retry_count: 0,\n created_at: Utc::now(),\n }\n }\n\n /// Mark job as queued\n pub fn queue(&mut self) {\n self.status = JobStatus::Queued.as_str().to_string();\n }\n\n /// Assign to a runner\n pub fn assign(&mut self, runner_id: RunnerId) {\n self.status = JobStatus::Assigned.as_str().to_string();\n self.runner_id = Some(runner_id);\n }\n\n /// Mark as running\n pub fn start(&mut self) {\n self.status = JobStatus::Running.as_str().to_string();\n self.started_at = Some(Utc::now());\n }\n\n /// Mark as finished\n pub fn finish(&mut self, success: bool) {\n self.status = if success {\n JobStatus::Succeeded.as_str().to_string()\n } else {\n JobStatus::Failed.as_str().to_string()\n };\n self.finished_at = Some(Utc::now());\n }\n\n /// Mark as timed out\n pub fn timeout(&mut self) {\n self.status = JobStatus::TimedOut.as_str().to_string();\n self.finished_at = Some(Utc::now());\n }\n\n /// Increment retry count\n pub fn retry(&mut self) {\n self.retry_count += 1;\n self.status = JobStatus::Queued.as_str().to_string();\n self.started_at = None;\n self.finished_at = None;\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_job_status_as_str() {\n assert_eq!(JobStatus::Pending.as_str(), \"pending\");\n assert_eq!(JobStatus::Queued.as_str(), \"queued\");\n assert_eq!(JobStatus::Assigned.as_str(), \"assigned\");\n assert_eq!(JobStatus::Running.as_str(), \"running\");\n assert_eq!(JobStatus::Succeeded.as_str(), \"succeeded\");\n assert_eq!(JobStatus::Failed.as_str(), \"failed\");\n assert_eq!(JobStatus::Cancelled.as_str(), \"cancelled\");\n assert_eq!(JobStatus::TimedOut.as_str(), \"timed_out\");\n }\n\n #[test]\n fn test_job_status_from_str() {\n assert_eq!(JobStatus::from_str(\"pending\"), Some(JobStatus::Pending));\n assert_eq!(JobStatus::from_str(\"queued\"), Some(JobStatus::Queued));\n assert_eq!(JobStatus::from_str(\"running\"), Some(JobStatus::Running));\n assert_eq!(JobStatus::from_str(\"succeeded\"), Some(JobStatus::Succeeded));\n assert_eq!(JobStatus::from_str(\"failed\"), Some(JobStatus::Failed));\n assert_eq!(JobStatus::from_str(\"unknown\"), None);\n }\n\n #[test]\n fn test_job_status_is_terminal() {\n assert!(!JobStatus::Pending.is_terminal());\n assert!(!JobStatus::Queued.is_terminal());\n assert!(!JobStatus::Running.is_terminal());\n assert!(JobStatus::Succeeded.is_terminal());\n assert!(JobStatus::Failed.is_terminal());\n assert!(JobStatus::Cancelled.is_terminal());\n assert!(JobStatus::TimedOut.is_terminal());\n }\n\n #[test]\n fn test_job_creation() {\n let pipeline_run_id = PipelineRunId::new();\n let job = Job::new(pipeline_run_id, \"build\".to_string());\n assert_eq!(job.name, \"build\");\n assert_eq!(job.status, \"pending\");\n assert!(job.runner_id.is_none());\n assert_eq!(job.retry_count, 0);\n }\n\n #[test]\n fn test_job_queue() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.queue();\n assert_eq!(job.status, \"queued\");\n }\n\n #[test]\n fn test_job_assign() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n let runner_id = RunnerId::new();\n job.assign(runner_id);\n assert_eq!(job.status, \"assigned\");\n assert_eq!(job.runner_id, Some(runner_id));\n }\n\n #[test]\n fn test_job_start() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.start();\n assert_eq!(job.status, \"running\");\n assert!(job.started_at.is_some());\n }\n\n #[test]\n fn test_job_finish_success() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.finish(true);\n assert_eq!(job.status, \"succeeded\");\n assert!(job.finished_at.is_some());\n }\n\n #[test]\n fn test_job_finish_failure() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.finish(false);\n assert_eq!(job.status, \"failed\");\n assert!(job.finished_at.is_some());\n }\n\n #[test]\n fn test_job_timeout() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.timeout();\n assert_eq!(job.status, \"timed_out\");\n assert!(job.finished_at.is_some());\n }\n\n #[test]\n fn test_job_retry() {\n let pipeline_run_id = PipelineRunId::new();\n let mut job = Job::new(pipeline_run_id, \"build\".to_string());\n job.start();\n job.finish(false);\n job.retry();\n assert_eq!(job.retry_count, 1);\n assert_eq!(job.status, \"queued\");\n assert!(job.started_at.is_none());\n assert!(job.finished_at.is_none());\n }\n\n #[test]\n fn test_job_id_unique() {\n let pipeline_run_id = PipelineRunId::new();\n let job1 = Job::new(pipeline_run_id, \"build\".to_string());\n let job2 = Job::new(pipeline_run_id, \"build\".to_string());\n assert_ne!(job1.id, job2.id);\n }\n}\n","traces":[{"line":21,"address":[6417920],"length":1,"stats":{"Line":9}},{"line":22,"address":[6417925],"length":1,"stats":{"Line":3}},{"line":23,"address":[6417956],"length":1,"stats":{"Line":10}},{"line":24,"address":[6417982],"length":1,"stats":{"Line":1}},{"line":25,"address":[6418008],"length":1,"stats":{"Line":1}},{"line":26,"address":[6418031],"length":1,"stats":{"Line":1}},{"line":27,"address":[6418054],"length":1,"stats":{"Line":1}},{"line":28,"address":[6418077],"length":1,"stats":{"Line":1}},{"line":29,"address":[6418100],"length":1,"stats":{"Line":1}},{"line":30,"address":[6418123],"length":1,"stats":{"Line":1}},{"line":35,"address":[6418160],"length":1,"stats":{"Line":1}},{"line":37,"address":[6418183,6418236],"length":1,"stats":{"Line":2}},{"line":38,"address":[6418213,6418278],"length":1,"stats":{"Line":2}},{"line":39,"address":[6418320,6418255],"length":1,"stats":{"Line":1}},{"line":40,"address":[6418362,6418297],"length":1,"stats":{"Line":2}},{"line":41,"address":[6418404,6418339],"length":1,"stats":{"Line":2}},{"line":42,"address":[6418381,6418443],"length":1,"stats":{"Line":2}},{"line":43,"address":[6418420,6418482],"length":1,"stats":{"Line":1}},{"line":44,"address":[6418496,6418459],"length":1,"stats":{"Line":1}},{"line":45,"address":[6416025],"length":1,"stats":{"Line":1}},{"line":50,"address":[6417872],"length":1,"stats":{"Line":1}},{"line":51,"address":[6415416],"length":1,"stats":{"Line":1}},{"line":52,"address":[6417877],"length":1,"stats":{"Line":1}},{"line":74,"address":[6413552,6414071],"length":1,"stats":{"Line":2}},{"line":76,"address":[6415995],"length":1,"stats":{"Line":8}},{"line":79,"address":[6413752,6413682],"length":1,"stats":{"Line":5}},{"line":84,"address":[6416215],"length":1,"stats":{"Line":9}},{"line":89,"address":[6416528,6416624],"length":1,"stats":{"Line":1}},{"line":90,"address":[6416541,6416654,6416597],"length":1,"stats":{"Line":2}},{"line":94,"address":[6417168,6417271],"length":1,"stats":{"Line":1}},{"line":95,"address":[6414738,6414854],"length":1,"stats":{"Line":2}},{"line":96,"address":[6417333],"length":1,"stats":{"Line":1}},{"line":100,"address":[6416944,6417043],"length":1,"stats":{"Line":1}},{"line":101,"address":[6417016,6416958,6417074],"length":1,"stats":{"Line":2}},{"line":102,"address":[6417101],"length":1,"stats":{"Line":1}},{"line":106,"address":[6417392,6417527],"length":1,"stats":{"Line":2}},{"line":107,"address":[6415107,6415050,6414969,6415031],"length":1,"stats":{"Line":5}},{"line":108,"address":[6417450],"length":1,"stats":{"Line":1}},{"line":110,"address":[6417421],"length":1,"stats":{"Line":1}},{"line":112,"address":[6417584],"length":1,"stats":{"Line":2}},{"line":116,"address":[6415200,6415295],"length":1,"stats":{"Line":1}},{"line":117,"address":[6417720,6417778,6417662],"length":1,"stats":{"Line":2}},{"line":118,"address":[6417805],"length":1,"stats":{"Line":1}},{"line":122,"address":[6416822,6416688],"length":1,"stats":{"Line":1}},{"line":123,"address":[6416701,6416778],"length":1,"stats":{"Line":1}},{"line":124,"address":[6416852,6416795,6416726],"length":1,"stats":{"Line":2}},{"line":125,"address":[6416879],"length":1,"stats":{"Line":1}},{"line":126,"address":[6416903],"length":1,"stats":{"Line":1}}],"covered":48,"coverable":48},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","mod.rs"],"content":"//! Database models (simplified for MVP)\n\npub mod repo;\npub mod pipeline;\npub mod job;\npub mod runner;\npub mod user;\npub mod event;\npub mod artifact;\n\npub use repo::*;\npub use pipeline::*;\npub use job::*;\npub use runner::*;\npub use user::*;\npub use event::*;\npub use artifact::*;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","pipeline.rs"],"content":"//! Pipeline models\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::{PipelineId, PipelineRunId, RepoId};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Pipeline entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Pipeline {\n pub id: PipelineId,\n pub repo_id: RepoId,\n pub name: String,\n pub trigger_type: String,\n pub config: serde_json::Value,\n pub created_at: DateTime,\n}\n\n/// Trigger type\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum TriggerType {\n Push,\n Tag,\n PullRequest,\n Manual,\n}\n\nimpl TriggerType {\n pub fn as_str(&self) -> &'static str {\n match self {\n TriggerType::Push => \"push\",\n TriggerType::Tag => \"tag\",\n TriggerType::PullRequest => \"pull_request\",\n TriggerType::Manual => \"manual\",\n }\n }\n}\n\n/// Pipeline run status\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum PipelineStatus {\n Pending,\n Running,\n Succeeded,\n Failed,\n Cancelled,\n}\n\nimpl PipelineStatus {\n pub fn as_str(&self) -> &'static str {\n match self {\n PipelineStatus::Pending => \"pending\",\n PipelineStatus::Running => \"running\",\n PipelineStatus::Succeeded => \"succeeded\",\n PipelineStatus::Failed => \"failed\",\n PipelineStatus::Cancelled => \"cancelled\",\n }\n }\n}\n\n/// Pipeline run entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineRun {\n pub id: PipelineRunId,\n pub pipeline_id: PipelineId,\n pub repo_id: RepoId,\n pub status: String,\n pub triggered_by: String,\n pub commit_hash: String,\n pub started_at: Option>,\n pub finished_at: Option>,\n pub created_at: DateTime,\n}\n\nimpl PipelineRun {\n /// Create a new pipeline run\n pub fn new(\n pipeline_id: PipelineId,\n repo_id: RepoId,\n triggered_by: String,\n commit_hash: String,\n ) -> Self {\n Self {\n id: PipelineRunId::new(),\n pipeline_id,\n repo_id,\n status: PipelineStatus::Pending.as_str().to_string(),\n triggered_by,\n commit_hash,\n started_at: None,\n finished_at: None,\n created_at: Utc::now(),\n }\n }\n\n /// Mark as started\n pub fn start(&mut self) {\n self.status = PipelineStatus::Running.as_str().to_string();\n self.started_at = Some(Utc::now());\n }\n\n /// Mark as finished\n pub fn finish(&mut self, success: bool) {\n self.status = if success {\n PipelineStatus::Succeeded.as_str().to_string()\n } else {\n PipelineStatus::Failed.as_str().to_string()\n };\n self.finished_at = Some(Utc::now());\n }\n}\n\n/// Pipeline definition from config file\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineDefinition {\n pub name: String,\n pub trigger_on: Vec,\n pub jobs: Vec,\n pub environment: HashMap,\n}\n\n/// Job definition within a pipeline\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobDefinition {\n pub name: String,\n pub image: String,\n pub steps: Vec,\n pub needs: Vec,\n pub env: HashMap,\n}\n\n/// Step definition within a job\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct StepDefinition {\n pub name: String,\n pub run: String,\n pub env: Option>,\n pub working_directory: Option,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_pipeline_status_as_str() {\n assert_eq!(PipelineStatus::Pending.as_str(), \"pending\");\n assert_eq!(PipelineStatus::Running.as_str(), \"running\");\n assert_eq!(PipelineStatus::Succeeded.as_str(), \"succeeded\");\n assert_eq!(PipelineStatus::Failed.as_str(), \"failed\");\n assert_eq!(PipelineStatus::Cancelled.as_str(), \"cancelled\");\n }\n\n #[test]\n fn test_trigger_type_as_str() {\n assert_eq!(TriggerType::Push.as_str(), \"push\");\n assert_eq!(TriggerType::Tag.as_str(), \"tag\");\n assert_eq!(TriggerType::PullRequest.as_str(), \"pull_request\");\n assert_eq!(TriggerType::Manual.as_str(), \"manual\");\n }\n\n #[test]\n fn test_pipeline_run_creation() {\n let run = PipelineRun::new(\n PipelineId::new(),\n RepoId::new(),\n \"push\".to_string(),\n \"abc123\".to_string(),\n );\n assert_eq!(run.triggered_by, \"push\");\n assert_eq!(run.commit_hash, \"abc123\");\n assert_eq!(run.status, \"pending\");\n }\n\n #[test]\n fn test_pipeline_run_start() {\n let mut run = PipelineRun::new(\n PipelineId::new(),\n RepoId::new(),\n \"push\".to_string(),\n \"abc123\".to_string(),\n );\n run.start();\n assert_eq!(run.status, \"running\");\n assert!(run.started_at.is_some());\n }\n\n #[test]\n fn test_pipeline_run_finish_success() {\n let mut run = PipelineRun::new(\n PipelineId::new(),\n RepoId::new(),\n \"push\".to_string(),\n \"abc123\".to_string(),\n );\n run.start();\n run.finish(true);\n assert_eq!(run.status, \"succeeded\");\n assert!(run.finished_at.is_some());\n }\n\n #[test]\n fn test_pipeline_run_finish_failure() {\n let mut run = PipelineRun::new(\n PipelineId::new(),\n RepoId::new(),\n \"push\".to_string(),\n \"abc123\".to_string(),\n );\n run.start();\n run.finish(false);\n assert_eq!(run.status, \"failed\");\n assert!(run.finished_at.is_some());\n }\n\n #[test]\n fn test_pipeline_definition_serialization() {\n let def = PipelineDefinition {\n name: \"test\".to_string(),\n trigger_on: vec![TriggerType::Push],\n jobs: vec![],\n environment: HashMap::new(),\n };\n let json = serde_json::to_string(&def).unwrap();\n assert!(json.contains(\"test\"));\n }\n\n #[test]\n fn test_job_definition_serialization() {\n let job = JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n steps: vec![],\n needs: vec![],\n env: HashMap::new(),\n };\n let json = serde_json::to_string(&job).unwrap();\n assert!(json.contains(\"build\"));\n }\n\n #[test]\n fn test_step_definition_serialization() {\n let step = StepDefinition {\n name: \"cargo build\".to_string(),\n run: \"cargo build --release\".to_string(),\n env: None,\n working_directory: None,\n };\n let json = serde_json::to_string(&step).unwrap();\n assert!(json.contains(\"cargo build\"));\n }\n}\n","traces":[{"line":29,"address":[6065456],"length":1,"stats":{"Line":1}},{"line":30,"address":[14512725],"length":1,"stats":{"Line":1}},{"line":31,"address":[14176836],"length":1,"stats":{"Line":1}},{"line":32,"address":[6065515],"length":1,"stats":{"Line":1}},{"line":33,"address":[16628450],"length":1,"stats":{"Line":1}},{"line":34,"address":[10571625],"length":1,"stats":{"Line":1}},{"line":50,"address":[24565456],"length":1,"stats":{"Line":3}},{"line":51,"address":[21083765],"length":1,"stats":{"Line":2}},{"line":52,"address":[14176980],"length":1,"stats":{"Line":3}},{"line":53,"address":[10571723],"length":1,"stats":{"Line":1}},{"line":54,"address":[24565538],"length":1,"stats":{"Line":1}},{"line":55,"address":[14177049],"length":1,"stats":{"Line":1}},{"line":56,"address":[14177072],"length":1,"stats":{"Line":1}},{"line":77,"address":[21083100,21083031,21082448],"length":1,"stats":{"Line":5}},{"line":84,"address":[6064341],"length":1,"stats":{"Line":6}},{"line":87,"address":[10570478],"length":1,"stats":{"Line":5}},{"line":92,"address":[24564415],"length":1,"stats":{"Line":5}},{"line":97,"address":[16627982,16627888],"length":1,"stats":{"Line":3}},{"line":98,"address":[6407358,6407412,6407469],"length":1,"stats":{"Line":6}},{"line":99,"address":[14512391],"length":1,"stats":{"Line":3}},{"line":103,"address":[13075890,13075760],"length":1,"stats":{"Line":2}},{"line":104,"address":[24565216,24565145,24565081,24565160],"length":1,"stats":{"Line":6}},{"line":105,"address":[21083418],"length":1,"stats":{"Line":1}},{"line":107,"address":[10571293],"length":1,"stats":{"Line":1}},{"line":109,"address":[6407754],"length":1,"stats":{"Line":2}}],"covered":25,"coverable":25},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","repo.rs"],"content":"//! Repository model\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::{RepoId, UserId};\nuse serde::{Deserialize, Serialize};\n\n/// Repository visibility\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum Visibility {\n Public,\n Private,\n}\n\nimpl Visibility {\n pub fn as_str(&self) -> &'static str {\n match self {\n Visibility::Public => \"public\",\n Visibility::Private => \"private\",\n }\n }\n}\n\n/// Repository entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Repository {\n pub id: RepoId,\n pub name: String,\n pub owner_id: UserId,\n pub visibility: String,\n pub git_path: String,\n pub created_at: DateTime,\n pub updated_at: DateTime,\n}\n\nimpl Repository {\n /// Create a new repository\n pub fn new(name: String, owner_id: UserId, git_path: String) -> Self {\n let now = Utc::now();\n Self {\n id: RepoId::new(),\n name,\n owner_id,\n visibility: Visibility::Private.as_str().to_string(),\n git_path,\n created_at: now,\n updated_at: now,\n }\n }\n\n /// Check if repository is public\n pub fn is_public(&self) -> bool {\n self.visibility == \"public\"\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_visibility_as_str() {\n assert_eq!(Visibility::Public.as_str(), \"public\");\n assert_eq!(Visibility::Private.as_str(), \"private\");\n }\n\n #[test]\n fn test_repository_creation() {\n let owner_id = UserId::new();\n let repo = Repository::new(\n \"test-repo\".to_string(),\n owner_id,\n \"/git/test-repo\".to_string(),\n );\n assert_eq!(repo.name, \"test-repo\");\n assert_eq!(repo.owner_id, owner_id);\n assert_eq!(repo.visibility, \"private\");\n assert!(!repo.is_public());\n }\n\n #[test]\n fn test_repository_public_visibility() {\n let owner_id = UserId::new();\n let mut repo = Repository::new(\n \"public-repo\".to_string(),\n owner_id,\n \"/git/public-repo\".to_string(),\n );\n repo.visibility = Visibility::Public.as_str().to_string();\n assert!(repo.is_public());\n }\n\n #[test]\n fn test_repository_id_unique() {\n let owner_id = UserId::new();\n let repo1 = Repository::new(\"repo1\".to_string(), owner_id, \"/git/repo1\".to_string());\n let repo2 = Repository::new(\"repo2\".to_string(), owner_id, \"/git/repo2\".to_string());\n assert_ne!(repo1.id, repo2.id);\n }\n}\n","traces":[{"line":15,"address":[13944400],"length":1,"stats":{"Line":3}},{"line":16,"address":[6192293],"length":1,"stats":{"Line":3}},{"line":17,"address":[6192331],"length":1,"stats":{"Line":1}},{"line":18,"address":[6192308],"length":1,"stats":{"Line":5}},{"line":37,"address":[6192196,6191728,6192212],"length":1,"stats":{"Line":5}},{"line":38,"address":[6174896],"length":1,"stats":{"Line":5}},{"line":40,"address":[13943945],"length":1,"stats":{"Line":5}},{"line":43,"address":[13943991,13944070],"length":1,"stats":{"Line":10}},{"line":51,"address":[6175392],"length":1,"stats":{"Line":2}},{"line":52,"address":[6175397],"length":1,"stats":{"Line":1}}],"covered":10,"coverable":10},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","runner.rs"],"content":"//! Runner model\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::RunnerId;\nuse serde::{Deserialize, Serialize};\n\n/// Runner type\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum RunnerType {\n Docker,\n Firecracker,\n BareMetal,\n}\n\nimpl RunnerType {\n pub fn as_str(&self) -> &'static str {\n match self {\n RunnerType::Docker => \"docker\",\n RunnerType::Firecracker => \"firecracker\",\n RunnerType::BareMetal => \"bare_metal\",\n }\n }\n}\n\n/// Runner status\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum RunnerStatus {\n Online,\n Busy,\n Offline,\n}\n\nimpl RunnerStatus {\n pub fn as_str(&self) -> &'static str {\n match self {\n RunnerStatus::Online => \"online\",\n RunnerStatus::Busy => \"busy\",\n RunnerStatus::Offline => \"offline\",\n }\n }\n}\n\n/// Runner entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Runner {\n pub id: RunnerId,\n pub name: String,\n pub runner_type: String,\n pub status: String,\n pub last_heartbeat: Option>,\n pub capacity: i32,\n pub created_at: DateTime,\n}\n\nimpl Runner {\n /// Create a new runner\n pub fn new(name: String, runner_type: RunnerType, capacity: i32) -> Self {\n Self {\n id: RunnerId::new(),\n name,\n runner_type: runner_type.as_str().to_string(),\n status: RunnerStatus::Online.as_str().to_string(),\n last_heartbeat: Some(Utc::now()),\n capacity,\n created_at: Utc::now(),\n }\n }\n\n /// Update heartbeat\n pub fn heartbeat(&mut self) {\n self.last_heartbeat = Some(Utc::now());\n }\n\n /// Mark as busy\n pub fn set_busy(&mut self) {\n self.status = RunnerStatus::Busy.as_str().to_string();\n }\n\n /// Mark as online\n pub fn set_online(&mut self) {\n self.status = RunnerStatus::Online.as_str().to_string();\n }\n\n /// Check if runner is healthy (heartbeat within threshold)\n pub fn is_healthy(&self, threshold_secs: i64) -> bool {\n if let Some(heartbeat) = self.last_heartbeat {\n let age = Utc::now() - heartbeat;\n age.num_seconds() < threshold_secs\n } else {\n false\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_runner_creation() {\n let runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::Docker,\n 4,\n );\n assert_eq!(runner.name, \"test-runner\");\n assert_eq!(runner.runner_type, \"docker\");\n assert_eq!(runner.capacity, 4);\n assert_eq!(runner.status, \"online\");\n }\n\n #[test]\n fn test_runner_heartbeat() {\n let mut runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::Docker,\n 2,\n );\n runner.heartbeat();\n assert!(runner.last_heartbeat.is_some());\n }\n\n #[test]\n fn test_runner_set_busy() {\n let mut runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::Firecracker,\n 2,\n );\n runner.set_busy();\n assert_eq!(runner.status, \"busy\");\n }\n\n #[test]\n fn test_runner_set_online() {\n let mut runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::BareMetal,\n 1,\n );\n runner.set_busy();\n runner.set_online();\n assert_eq!(runner.status, \"online\");\n }\n\n #[test]\n fn test_runner_is_healthy() {\n let mut runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::Docker,\n 2,\n );\n runner.heartbeat();\n assert!(runner.is_healthy(60));\n assert!(!runner.is_healthy(0));\n }\n\n #[test]\n fn test_runner_is_not_healthy_without_heartbeat() {\n // Create a runner and manually set last_heartbeat to None\n let mut runner = Runner::new(\n \"test-runner\".to_string(),\n RunnerType::Docker,\n 2,\n );\n runner.last_heartbeat = None;\n assert!(!runner.is_healthy(60));\n }\n\n #[test]\n fn test_runner_type_as_str() {\n assert_eq!(RunnerType::Docker.as_str(), \"docker\");\n assert_eq!(RunnerType::Firecracker.as_str(), \"firecracker\");\n assert_eq!(RunnerType::BareMetal.as_str(), \"bare_metal\");\n }\n\n #[test]\n fn test_runner_status_as_str() {\n assert_eq!(RunnerStatus::Online.as_str(), \"online\");\n assert_eq!(RunnerStatus::Busy.as_str(), \"busy\");\n assert_eq!(RunnerStatus::Offline.as_str(), \"offline\");\n }\n}\n","traces":[{"line":16,"address":[24499392],"length":1,"stats":{"Line":12}},{"line":17,"address":[21017701],"length":1,"stats":{"Line":11}},{"line":18,"address":[13002389],"length":1,"stats":{"Line":13}},{"line":19,"address":[10505660],"length":1,"stats":{"Line":2}},{"line":20,"address":[14446883],"length":1,"stats":{"Line":2}},{"line":34,"address":[14103264],"length":1,"stats":{"Line":5}},{"line":35,"address":[6071493],"length":1,"stats":{"Line":6}},{"line":36,"address":[24499557],"length":1,"stats":{"Line":5}},{"line":37,"address":[14446988],"length":1,"stats":{"Line":1}},{"line":38,"address":[21017907],"length":1,"stats":{"Line":1}},{"line":57,"address":[16555296,16555914],"length":1,"stats":{"Line":4}},{"line":59,"address":[6334791],"length":1,"stats":{"Line":5}},{"line":61,"address":[21018430,21018504],"length":1,"stats":{"Line":9}},{"line":62,"address":[6334984,6335060],"length":1,"stats":{"Line":13}},{"line":63,"address":[14447719,14447779],"length":1,"stats":{"Line":14}},{"line":65,"address":[10506609],"length":1,"stats":{"Line":5}},{"line":70,"address":[16556112],"length":1,"stats":{"Line":2}},{"line":71,"address":[13003773],"length":1,"stats":{"Line":2}},{"line":75,"address":[14104384,14104480],"length":1,"stats":{"Line":2}},{"line":76,"address":[6335534,6335477,6335421],"length":1,"stats":{"Line":4}},{"line":80,"address":[14447232,14447328],"length":1,"stats":{"Line":1}},{"line":81,"address":[14447358,14447245,14447301],"length":1,"stats":{"Line":2}},{"line":85,"address":[14447056],"length":1,"stats":{"Line":1}},{"line":86,"address":[14103416,14103542],"length":1,"stats":{"Line":2}},{"line":87,"address":[21018020],"length":1,"stats":{"Line":1}},{"line":88,"address":[14447178],"length":1,"stats":{"Line":1}},{"line":90,"address":[6334568],"length":1,"stats":{"Line":1}}],"covered":27,"coverable":27},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","models","user.rs"],"content":"//! User model\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::UserId;\nuse serde::{Deserialize, Serialize};\n\n/// User entity\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct User {\n pub id: UserId,\n pub username: String,\n pub email: String,\n pub password_hash: String,\n pub created_at: DateTime,\n}\n\nimpl User {\n /// Create a new user\n pub fn new(username: String, email: String, password_hash: String) -> Self {\n Self {\n id: UserId::new(),\n username,\n email,\n password_hash,\n created_at: Utc::now(),\n }\n }\n}\n\n/// User role\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\npub enum Role {\n Admin,\n Maintainer,\n Developer,\n ReadOnly,\n}\n\nimpl Role {\n pub fn as_str(&self) -> &'static str {\n match self {\n Role::Admin => \"admin\",\n Role::Maintainer => \"maintainer\",\n Role::Developer => \"developer\",\n Role::ReadOnly => \"read_only\",\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_user_creation() {\n let user = User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash123\".to_string(),\n );\n assert_eq!(user.username, \"testuser\");\n assert_eq!(user.email, \"test@example.com\");\n assert_eq!(user.password_hash, \"hash123\");\n }\n\n #[test]\n fn test_role_as_str() {\n assert_eq!(Role::Admin.as_str(), \"admin\");\n assert_eq!(Role::Maintainer.as_str(), \"maintainer\");\n assert_eq!(Role::Developer.as_str(), \"developer\");\n assert_eq!(Role::ReadOnly.as_str(), \"read_only\");\n }\n}\n","traces":[{"line":19,"address":[6434144,6434587,6434684],"length":1,"stats":{"Line":13}},{"line":21,"address":[6434176],"length":1,"stats":{"Line":8}},{"line":25,"address":[6434377],"length":1,"stats":{"Line":14}},{"line":40,"address":[6434000],"length":1,"stats":{"Line":1}},{"line":41,"address":[6434005],"length":1,"stats":{"Line":1}},{"line":42,"address":[6434036],"length":1,"stats":{"Line":1}},{"line":43,"address":[6434059],"length":1,"stats":{"Line":1}},{"line":44,"address":[6434082],"length":1,"stats":{"Line":1}},{"line":45,"address":[6434105],"length":1,"stats":{"Line":1}}],"covered":9,"coverable":9},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","src","queries.rs"],"content":"//! Database queries implementation using SQLite\n//!\n//! This module provides real SQLite query implementations for all database operations.\n\nuse gitforce_common::{Error, RepoId, Result, RunnerId, UserId, PipelineId, PipelineRunId, JobId};\nuse sqlx::Row;\nuse uuid::Uuid;\nuse chrono::{DateTime, Utc};\nuse crate::Pool;\n\n// ============================================================================\n// Repository Queries\n// ============================================================================\n\npub struct RepoQueries;\n\nimpl RepoQueries {\n /// Create a new repository\n pub async fn create(pool: &Pool, repo: &crate::models::Repository) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO repositories (id, name, owner_id, visibility, git_path, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n \"#,\n )\n .bind(repo.id.to_string())\n .bind(&repo.name)\n .bind(repo.owner_id.to_string())\n .bind(&repo.visibility)\n .bind(&repo.git_path)\n .bind(repo.created_at.to_rfc3339())\n .bind(repo.updated_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create repository: {}\", e)))?;\n Ok(())\n }\n\n /// Get a repository by ID\n pub async fn get(pool: &Pool, id: RepoId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM repositories WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get repository: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::Repository {\n id: RepoId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n owner_id: UserId::from(Uuid::parse_str(&row.get::(\"owner_id\")).unwrap()),\n visibility: row.get(\"visibility\"),\n git_path: row.get(\"git_path\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n updated_at: DateTime::parse_from_rfc3339(&row.get::(\"updated_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// List repositories by owner\n pub async fn list_by_owner(pool: &Pool, owner_id: UserId) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM repositories WHERE owner_id = ?\")\n .bind(owner_id.to_string())\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list repositories: {}\", e)))?;\n\n let repos = rows\n .into_iter()\n .map(|row| crate::models::Repository {\n id: RepoId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n owner_id: UserId::from(Uuid::parse_str(&row.get::(\"owner_id\")).unwrap()),\n visibility: row.get(\"visibility\"),\n git_path: row.get(\"git_path\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n updated_at: DateTime::parse_from_rfc3339(&row.get::(\"updated_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(repos)\n }\n\n /// Delete a repository\n pub async fn delete(pool: &Pool, id: RepoId) -> Result<()> {\n sqlx::query(\"DELETE FROM repositories WHERE id = ?\")\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to delete repository: {}\", e)))?;\n Ok(())\n }\n\n /// List all repositories\n pub async fn list(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM repositories ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list repositories: {}\", e)))?;\n\n let repos = rows\n .into_iter()\n .map(|row| crate::models::Repository {\n id: RepoId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n owner_id: UserId::from(Uuid::parse_str(&row.get::(\"owner_id\")).unwrap()),\n visibility: row.get(\"visibility\"),\n git_path: row.get(\"git_path\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n updated_at: DateTime::parse_from_rfc3339(&row.get::(\"updated_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(repos)\n }\n}\n\n// ============================================================================\n// User Queries\n// ============================================================================\n\npub struct UserQueries;\n\nimpl UserQueries {\n /// Create a new user\n pub async fn create(pool: &Pool, user: &crate::models::User) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO users (id, username, email, password_hash, created_at)\n VALUES (?, ?, ?, ?, ?)\n \"#,\n )\n .bind(user.id.to_string())\n .bind(&user.username)\n .bind(&user.email)\n .bind(&user.password_hash)\n .bind(user.created_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create user: {}\", e)))?;\n Ok(())\n }\n\n /// Get a user by ID\n pub async fn get(pool: &Pool, id: UserId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM users WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get user: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::User {\n id: UserId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n username: row.get(\"username\"),\n email: row.get(\"email\"),\n password_hash: row.get(\"password_hash\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// Get a user by username\n pub async fn get_by_username(pool: &Pool, username: &str) -> Result> {\n let row = sqlx::query(\"SELECT * FROM users WHERE username = ?\")\n .bind(username)\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get user by username: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::User {\n id: UserId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n username: row.get(\"username\"),\n email: row.get(\"email\"),\n password_hash: row.get(\"password_hash\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// List all users\n pub async fn list(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM users ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list users: {}\", e)))?;\n\n let users = rows\n .into_iter()\n .map(|row| crate::models::User {\n id: UserId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n username: row.get(\"username\"),\n email: row.get(\"email\"),\n password_hash: row.get(\"password_hash\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(users)\n }\n}\n\n// ============================================================================\n// Pipeline Queries\n// ============================================================================\n\npub struct PipelineQueries;\n\nimpl PipelineQueries {\n /// Create a new pipeline\n pub async fn create(pool: &Pool, pipeline: &crate::models::Pipeline) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO pipelines (id, repo_id, name, trigger_type, config, created_at)\n VALUES (?, ?, ?, ?, ?, ?)\n \"#,\n )\n .bind(pipeline.id.to_string())\n .bind(pipeline.repo_id.to_string())\n .bind(&pipeline.name)\n .bind(&pipeline.trigger_type)\n .bind(pipeline.config.to_string())\n .bind(pipeline.created_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create pipeline: {}\", e)))?;\n Ok(())\n }\n\n /// Get a pipeline by ID\n pub async fn get(pool: &Pool, id: PipelineId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM pipelines WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get pipeline: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::Pipeline {\n id: PipelineId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n name: row.get(\"name\"),\n trigger_type: row.get(\"trigger_type\"),\n config: serde_json::from_str(&row.get::(\"config\"))\n .unwrap_or_default(),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// List pipelines by repository\n pub async fn list_by_repo(pool: &Pool, repo_id: RepoId) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM pipelines WHERE repo_id = ? ORDER BY created_at DESC\")\n .bind(repo_id.to_string())\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list pipelines: {}\", e)))?;\n\n let pipelines = rows\n .into_iter()\n .map(|row| crate::models::Pipeline {\n id: PipelineId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n name: row.get(\"name\"),\n trigger_type: row.get(\"trigger_type\"),\n config: serde_json::from_str(&row.get::(\"config\"))\n .unwrap_or_default(),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(pipelines)\n }\n\n /// List all pipelines\n pub async fn list(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM pipelines ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list pipelines: {}\", e)))?;\n\n let pipelines = rows\n .into_iter()\n .map(|row| crate::models::Pipeline {\n id: PipelineId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n name: row.get(\"name\"),\n trigger_type: row.get(\"trigger_type\"),\n config: serde_json::from_str(&row.get::(\"config\"))\n .unwrap_or_default(),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(pipelines)\n }\n}\n\n// ============================================================================\n// Pipeline Run Queries\n// ============================================================================\n\npub struct PipelineRunQueries;\n\nimpl PipelineRunQueries {\n /// Create a new pipeline run\n pub async fn create(pool: &Pool, run: &crate::models::PipelineRun) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO pipeline_runs (id, pipeline_id, repo_id, status, triggered_by, commit_hash, started_at, finished_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n \"#,\n )\n .bind(run.id.to_string())\n .bind(run.pipeline_id.to_string())\n .bind(run.repo_id.to_string())\n .bind(&run.status)\n .bind(&run.triggered_by)\n .bind(&run.commit_hash)\n .bind(run.started_at.map(|dt| dt.to_rfc3339()))\n .bind(run.finished_at.map(|dt| dt.to_rfc3339()))\n .bind(run.created_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create pipeline run: {}\", e)))?;\n Ok(())\n }\n\n /// Get a pipeline run by ID\n pub async fn get(pool: &Pool, id: PipelineRunId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM pipeline_runs WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get pipeline run: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::PipelineRun {\n id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::(\"pipeline_id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n status: row.get(\"status\"),\n triggered_by: row.get(\"triggered_by\"),\n commit_hash: row.get(\"commit_hash\"),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// Update pipeline run status\n pub async fn update_status(pool: &Pool, id: PipelineRunId, status: &str) -> Result<()> {\n sqlx::query(\"UPDATE pipeline_runs SET status = ? WHERE id = ?\")\n .bind(status)\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to update pipeline run status: {}\", e)))?;\n Ok(())\n }\n\n /// List pipeline runs by pipeline\n pub async fn list_by_pipeline(pool: &Pool, pipeline_id: PipelineId) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM pipeline_runs WHERE pipeline_id = ? ORDER BY created_at DESC\")\n .bind(pipeline_id.to_string())\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list pipeline runs: {}\", e)))?;\n\n let runs = rows\n .into_iter()\n .map(|row| crate::models::PipelineRun {\n id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::(\"pipeline_id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n status: row.get(\"status\"),\n triggered_by: row.get(\"triggered_by\"),\n commit_hash: row.get(\"commit_hash\"),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(runs)\n }\n\n /// List all pipeline runs\n pub async fn list(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM pipeline_runs ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list pipeline runs: {}\", e)))?;\n\n let runs = rows\n .into_iter()\n .map(|row| crate::models::PipelineRun {\n id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_id: PipelineId::from(Uuid::parse_str(&row.get::(\"pipeline_id\")).unwrap()),\n repo_id: RepoId::from(Uuid::parse_str(&row.get::(\"repo_id\")).unwrap()),\n status: row.get(\"status\"),\n triggered_by: row.get(\"triggered_by\"),\n commit_hash: row.get(\"commit_hash\"),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(runs)\n }\n}\n\n// ============================================================================\n// Job Queries\n// ============================================================================\n\npub struct JobQueries;\n\nimpl JobQueries {\n /// Create a new job\n pub async fn create(pool: &Pool, job: &crate::models::Job) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO jobs (id, pipeline_run_id, name, status, runner_id, started_at, finished_at, retry_count, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n \"#,\n )\n .bind(job.id.to_string())\n .bind(job.pipeline_run_id.to_string())\n .bind(&job.name)\n .bind(&job.status)\n .bind(job.runner_id.map(|id| id.to_string()))\n .bind(job.started_at.map(|dt| dt.to_rfc3339()))\n .bind(job.finished_at.map(|dt| dt.to_rfc3339()))\n .bind(job.retry_count)\n .bind(job.created_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create job: {}\", e)))?;\n Ok(())\n }\n\n /// Get a job by ID\n pub async fn get(pool: &Pool, id: JobId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM jobs WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get job: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::Job {\n id: JobId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"pipeline_run_id\")).unwrap()),\n name: row.get(\"name\"),\n status: row.get(\"status\"),\n runner_id: row.get::, _>(\"runner_id\")\n .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n retry_count: row.get(\"retry_count\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// Update job status\n pub async fn update_status(pool: &Pool, id: JobId, status: &str) -> Result<()> {\n sqlx::query(\"UPDATE jobs SET status = ? WHERE id = ?\")\n .bind(status)\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to update job status: {}\", e)))?;\n Ok(())\n }\n\n /// Assign a runner to a job\n pub async fn assign(pool: &Pool, id: JobId, runner_id: RunnerId) -> Result<()> {\n sqlx::query(\"UPDATE jobs SET runner_id = ? WHERE id = ?\")\n .bind(runner_id.to_string())\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to assign job: {}\", e)))?;\n Ok(())\n }\n\n /// List jobs by pipeline run\n pub async fn list_by_run(pool: &Pool, run_id: PipelineRunId) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM jobs WHERE pipeline_run_id = ? ORDER BY created_at ASC\")\n .bind(run_id.to_string())\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list jobs: {}\", e)))?;\n\n let jobs = rows\n .into_iter()\n .map(|row| crate::models::Job {\n id: JobId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"pipeline_run_id\")).unwrap()),\n name: row.get(\"name\"),\n status: row.get(\"status\"),\n runner_id: row.get::, _>(\"runner_id\")\n .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n retry_count: row.get(\"retry_count\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(jobs)\n }\n\n /// List all pending jobs\n pub async fn list_pending(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at ASC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list pending jobs: {}\", e)))?;\n\n let jobs = rows\n .into_iter()\n .map(|row| crate::models::Job {\n id: JobId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n pipeline_run_id: PipelineRunId::from(Uuid::parse_str(&row.get::(\"pipeline_run_id\")).unwrap()),\n name: row.get(\"name\"),\n status: row.get(\"status\"),\n runner_id: row.get::, _>(\"runner_id\")\n .and_then(|s| Uuid::parse_str(&s).ok().map(RunnerId::from)),\n started_at: row.get::, _>(\"started_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n finished_at: row.get::, _>(\"finished_at\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n retry_count: row.get(\"retry_count\"),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(jobs)\n }\n}\n\n// ============================================================================\n// Runner Queries\n// ============================================================================\n\npub struct RunnerQueries;\n\nimpl RunnerQueries {\n /// Create a new runner\n pub async fn create(pool: &Pool, runner: &crate::models::Runner) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO runners (id, name, runner_type, status, capacity, labels, last_heartbeat, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n \"#,\n )\n .bind(runner.id.to_string())\n .bind(&runner.name)\n .bind(&runner.runner_type)\n .bind(&runner.status)\n .bind(runner.capacity)\n .bind(\"[]\") // labels as JSON array\n .bind(runner.last_heartbeat.map(|dt| dt.to_rfc3339()))\n .bind(runner.created_at.to_rfc3339())\n .bind(runner.created_at.to_rfc3339()) // updated_at same as created_at for new runner\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create runner: {}\", e)))?;\n Ok(())\n }\n\n /// Get a runner by ID\n pub async fn get(pool: &Pool, id: RunnerId) -> Result> {\n let row = sqlx::query(\"SELECT * FROM runners WHERE id = ?\")\n .bind(id.to_string())\n .fetch_optional(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to get runner: {}\", e)))?;\n\n match row {\n Some(row) => Ok(Some(crate::models::Runner {\n id: RunnerId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n runner_type: row.get(\"runner_type\"),\n status: row.get(\"status\"),\n capacity: row.get(\"capacity\"),\n last_heartbeat: row.get::, _>(\"last_heartbeat\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })),\n None => Ok(None),\n }\n }\n\n /// Update runner heartbeat\n pub async fn heartbeat(pool: &Pool, id: RunnerId) -> Result<()> {\n sqlx::query(\"UPDATE runners SET last_heartbeat = ? WHERE id = ?\")\n .bind(Utc::now().to_rfc3339())\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to update heartbeat: {}\", e)))?;\n Ok(())\n }\n\n /// Update runner status\n pub async fn update_status(pool: &Pool, id: RunnerId, status: &str) -> Result<()> {\n sqlx::query(\"UPDATE runners SET status = ? WHERE id = ?\")\n .bind(status)\n .bind(id.to_string())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to update runner status: {}\", e)))?;\n Ok(())\n }\n\n /// List all runners\n pub async fn list(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM runners ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list runners: {}\", e)))?;\n\n let runners = rows\n .into_iter()\n .map(|row| crate::models::Runner {\n id: RunnerId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n runner_type: row.get(\"runner_type\"),\n status: row.get(\"status\"),\n capacity: row.get(\"capacity\"),\n last_heartbeat: row.get::, _>(\"last_heartbeat\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(runners)\n }\n\n /// List online runners\n pub async fn list_online(pool: &Pool) -> Result> {\n let rows = sqlx::query(\"SELECT * FROM runners WHERE status = 'online' ORDER BY created_at DESC\")\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list online runners: {}\", e)))?;\n\n let runners = rows\n .into_iter()\n .map(|row| crate::models::Runner {\n id: RunnerId::from(Uuid::parse_str(&row.get::(\"id\")).unwrap()),\n name: row.get(\"name\"),\n runner_type: row.get(\"runner_type\"),\n status: row.get(\"status\"),\n capacity: row.get(\"capacity\"),\n last_heartbeat: row.get::, _>(\"last_heartbeat\")\n .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())\n .map(|dt| dt.with_timezone(&Utc)),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(runners)\n }\n}\n\n// ============================================================================\n// Event Queries\n// ============================================================================\n\npub struct EventQueries;\n\nimpl EventQueries {\n /// Create a new event\n pub async fn create(pool: &Pool, event: &crate::models::Event) -> Result<()> {\n sqlx::query(\n r#\"\n INSERT INTO events (id, event_type, payload, created_at)\n VALUES (?, ?, ?, ?)\n \"#,\n )\n .bind(event.id.to_string())\n .bind(&event.event_type)\n .bind(event.payload.to_string())\n .bind(event.created_at.to_rfc3339())\n .execute(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to create event: {}\", e)))?;\n Ok(())\n }\n\n /// List events by type\n pub async fn list_by_type(pool: &Pool, event_type: &str, limit: i64) -> Result> {\n let rows = sqlx::query(\n \"SELECT * FROM events WHERE event_type = ? ORDER BY created_at DESC LIMIT ?\"\n )\n .bind(event_type)\n .bind(limit)\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list events: {}\", e)))?;\n\n let events = rows\n .into_iter()\n .map(|row| crate::models::Event {\n id: Uuid::parse_str(&row.get::(\"id\")).unwrap(),\n event_type: row.get(\"event_type\"),\n payload: serde_json::from_str(&row.get::(\"payload\"))\n .unwrap_or_default(),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(events)\n }\n\n /// List recent events\n pub async fn list_recent(pool: &Pool, limit: i64) -> Result> {\n let rows = sqlx::query(\n \"SELECT * FROM events ORDER BY created_at DESC LIMIT ?\"\n )\n .bind(limit)\n .fetch_all(pool.pool())\n .await\n .map_err(|e| Error::database(format!(\"failed to list events: {}\", e)))?;\n\n let events = rows\n .into_iter()\n .map(|row| crate::models::Event {\n id: Uuid::parse_str(&row.get::(\"id\")).unwrap(),\n event_type: row.get(\"event_type\"),\n payload: serde_json::from_str(&row.get::(\"payload\"))\n .unwrap_or_default(),\n created_at: DateTime::parse_from_rfc3339(&row.get::(\"created_at\"))\n .unwrap()\n .with_timezone(&Utc),\n })\n .collect();\n\n Ok(events)\n }\n}\n\n// ============================================================================\n// Tests\n// ============================================================================\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_repo_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first (repository has FK to owner)\n let user = crate::models::User::new(\n \"owner\".to_string(),\n \"owner@example.com\".to_string(),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = crate::models::Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/test-repo\".to_string(),\n );\n\n // Create\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // Get\n let found = RepoQueries::get(&pool, repo.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"test-repo\");\n\n // List by owner\n let repos = RepoQueries::list_by_owner(&pool, user.id).await.unwrap();\n assert_eq!(repos.len(), 1);\n\n // List all\n let all_repos = RepoQueries::list(&pool).await.unwrap();\n assert_eq!(all_repos.len(), 1);\n\n // Delete\n RepoQueries::delete(&pool, repo.id).await.unwrap();\n let found = RepoQueries::get(&pool, repo.id).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_user_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let user = crate::models::User::new(\n \"testuser\".to_string(),\n \"test@example.com\".to_string(),\n \"hash\".to_string(),\n );\n\n // Create\n UserQueries::create(&pool, &user).await.unwrap();\n\n // Get by ID\n let found = UserQueries::get(&pool, user.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().username, \"testuser\");\n\n // Get by username\n let found = UserQueries::get_by_username(&pool, \"testuser\").await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().email, \"test@example.com\");\n\n // Not found\n let found = UserQueries::get_by_username(&pool, \"nonexistent\").await.unwrap();\n assert!(found.is_none());\n\n // List all\n let all_users = UserQueries::list(&pool).await.unwrap();\n assert_eq!(all_users.len(), 1);\n }\n\n #[tokio::test]\n async fn test_runner_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let runner = crate::models::Runner::new(\n \"test-runner\".to_string(),\n crate::models::RunnerType::Docker,\n 2,\n );\n\n // Create\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // Get\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"test-runner\");\n\n // List\n let runners = RunnerQueries::list(&pool).await.unwrap();\n assert_eq!(runners.len(), 1);\n\n // Heartbeat\n RunnerQueries::heartbeat(&pool, runner.id).await.unwrap();\n\n // Update status\n RunnerQueries::update_status(&pool, runner.id, \"offline\").await.unwrap();\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"offline\");\n }\n\n #[tokio::test]\n async fn test_pipeline_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first\n let user = crate::models::User::new(\n \"owner\".to_string(),\n \"owner@example.com\".to_string(),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = crate::models::Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/test-repo\".to_string(),\n );\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = crate::models::Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n\n // Create\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n // Get\n let found = PipelineQueries::get(&pool, pipeline.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"Test Pipeline\");\n\n // List by repo\n let pipelines = PipelineQueries::list_by_repo(&pool, repo.id).await.unwrap();\n assert_eq!(pipelines.len(), 1);\n\n // List all\n let all_pipelines = PipelineQueries::list(&pool).await.unwrap();\n assert_eq!(all_pipelines.len(), 1);\n }\n\n #[tokio::test]\n async fn test_pipeline_run_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first\n let user = crate::models::User::new(\n \"owner\".to_string(),\n \"owner@example.com\".to_string(),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = crate::models::Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/test-repo\".to_string(),\n );\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = crate::models::Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = crate::models::PipelineRun::new(\n pipeline.id,\n repo.id,\n \"alice\".to_string(),\n \"abc123\".to_string(),\n );\n\n // Create\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Get\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().commit_hash, \"abc123\");\n\n // Update status\n PipelineRunQueries::update_status(&pool, run.id, \"running\").await.unwrap();\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"running\");\n\n // List by pipeline\n let runs = PipelineRunQueries::list_by_pipeline(&pool, pipeline.id).await.unwrap();\n assert_eq!(runs.len(), 1);\n\n // List all\n let all_runs = PipelineRunQueries::list(&pool).await.unwrap();\n assert_eq!(all_runs.len(), 1);\n }\n\n #[tokio::test]\n async fn test_job_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first\n let user = crate::models::User::new(\n \"owner\".to_string(),\n \"owner@example.com\".to_string(),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = crate::models::Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/test-repo\".to_string(),\n );\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = crate::models::Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = crate::models::PipelineRun::new(\n pipeline.id,\n repo.id,\n \"alice\".to_string(),\n \"abc123\".to_string(),\n );\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n let job = crate::models::Job::new(\n run.id,\n \"build\".to_string(),\n );\n\n // Create\n JobQueries::create(&pool, &job).await.unwrap();\n\n // Get\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"build\");\n\n // Update status\n JobQueries::update_status(&pool, job.id, \"running\").await.unwrap();\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"running\");\n\n // Assign runner\n let runner = crate::models::Runner::new(\n \"test-runner\".to_string(),\n crate::models::RunnerType::Docker,\n 2,\n );\n RunnerQueries::create(&pool, &runner).await.unwrap();\n JobQueries::assign(&pool, job.id, runner.id).await.unwrap();\n\n // List by run\n let jobs = JobQueries::list_by_run(&pool, run.id).await.unwrap();\n assert_eq!(jobs.len(), 1);\n }\n\n #[tokio::test]\n async fn test_job_queries_list_pending() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user, repo, pipeline, run, job\n let user = crate::models::User::new(\n \"owner\".to_string(),\n \"owner@example.com\".to_string(),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = crate::models::Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/test-repo\".to_string(),\n );\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = crate::models::Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = crate::models::PipelineRun::new(\n pipeline.id,\n repo.id,\n \"alice\".to_string(),\n \"abc123\".to_string(),\n );\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n let job = crate::models::Job::new(run.id, \"build\".to_string());\n JobQueries::create(&pool, &job).await.unwrap();\n\n // List pending jobs\n let pending = JobQueries::list_pending(&pool).await.unwrap();\n assert_eq!(pending.len(), 1);\n assert_eq!(pending[0].name, \"build\");\n }\n\n #[tokio::test]\n async fn test_runner_queries_list_online() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let runner = crate::models::Runner::new(\n \"test-runner\".to_string(),\n crate::models::RunnerType::Docker,\n 2,\n );\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // List online runners\n let online = RunnerQueries::list_online(&pool).await.unwrap();\n assert_eq!(online.len(), 1);\n }\n\n #[tokio::test]\n async fn test_event_queries_list_by_type_none() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let event = crate::models::Event::new(\n \"push.received\".to_string(),\n serde_json::json!({\"repo\": \"test\"}),\n );\n EventQueries::create(&pool, &event).await.unwrap();\n\n // List non-existent type\n let events = EventQueries::list_by_type(&pool, \"nonexistent.type\", 10).await.unwrap();\n assert!(events.is_empty());\n }\n\n #[tokio::test]\n async fn test_event_queries_list_recent_limit() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create multiple events\n for i in 0..5 {\n let event = crate::models::Event::new(\n \"push.received\".to_string(),\n serde_json::json!({\"repo\": format!(\"test{}\", i)}),\n );\n EventQueries::create(&pool, &event).await.unwrap();\n }\n\n // List with limit 3\n let recent = EventQueries::list_recent(&pool, 3).await.unwrap();\n assert_eq!(recent.len(), 3);\n }\n\n #[tokio::test]\n async fn test_event_queries_empty() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // List when no events\n let events = EventQueries::list_by_type(&pool, \"push.received\", 10).await.unwrap();\n assert!(events.is_empty());\n\n let recent = EventQueries::list_recent(&pool, 10).await.unwrap();\n assert!(recent.is_empty());\n }\n\n #[tokio::test]\n async fn test_job_queries_get_nonexistent() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let found = JobQueries::get(&pool, JobId::new()).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_pipeline_queries_get_nonexistent() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let found = PipelineQueries::get(&pool, PipelineId::new()).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_pipeline_run_queries_get_nonexistent() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let found = PipelineRunQueries::get(&pool, PipelineRunId::new()).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_runner_queries_get_nonexistent() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let found = RunnerQueries::get(&pool, RunnerId::new()).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_user_queries_get_nonexistent() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let found = UserQueries::get(&pool, UserId::new()).await.unwrap();\n assert!(found.is_none());\n }\n\n #[tokio::test]\n async fn test_event_queries() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let event = crate::models::Event::new(\n \"push.received\".to_string(),\n serde_json::json!({\"repo\": \"test\"}),\n );\n\n // Create\n EventQueries::create(&pool, &event).await.unwrap();\n\n // List by type\n let events = EventQueries::list_by_type(&pool, \"push.received\", 10).await.unwrap();\n assert_eq!(events.len(), 1);\n assert_eq!(events[0].event_type, \"push.received\");\n\n // List recent\n let recent = EventQueries::list_recent(&pool, 10).await.unwrap();\n assert_eq!(recent.len(), 1);\n }\n}\n","traces":[{"line":19,"address":[16813824,16813837],"length":1,"stats":{"Line":16}},{"line":26,"address":[16742498,16742429,16743444,16742381,16742452],"length":1,"stats":{"Line":9}},{"line":27,"address":[13190194,13190261],"length":1,"stats":{"Line":9}},{"line":28,"address":[13190292,13190269,13190221,13190338,13191070],"length":1,"stats":{"Line":9}},{"line":29,"address":[6529682],"length":1,"stats":{"Line":4}},{"line":30,"address":[5919889],"length":1,"stats":{"Line":5}},{"line":31,"address":[13190501,13191048,13190570,13190417,13190524],"length":1,"stats":{"Line":9}},{"line":32,"address":[24702242,24702314,24702265,24702635],"length":1,"stats":{"Line":8}},{"line":33,"address":[24702600,24702386,24702440,24702370],"length":1,"stats":{"Line":9}},{"line":34,"address":[5920279,5919475,5920541,5920362,5920731],"length":1,"stats":{"Line":21}},{"line":35,"address":[24703022,24703350,24703101,24703328],"length":1,"stats":{"Line":4}},{"line":36,"address":[14294432],"length":1,"stats":{"Line":6}},{"line":40,"address":[10701101,10700499,10700978,10700623,10701129,10700464],"length":1,"stats":{"Line":4}},{"line":41,"address":[10701449,10700769,10701374,10700700,10701311,10700592,10700895,10701022,10701565],"length":1,"stats":{"Line":8}},{"line":42,"address":[10700708,10700731,10700660,10700777,10701107],"length":1,"stats":{"Line":2}},{"line":43,"address":[14642272,14642103,14642033,14642049],"length":1,"stats":{"Line":2}},{"line":44,"address":[5912762,5912474,5912957,5913148,5912853],"length":1,"stats":{"Line":4}},{"line":45,"address":[16736045,16738790,16735950,16738768],"length":1,"stats":{"Line":1}},{"line":47,"address":[16736314],"length":1,"stats":{"Line":1}},{"line":48,"address":[24696977,24695609],"length":1,"stats":{"Line":2}},{"line":49,"address":[13184188,13184256,13184033],"length":1,"stats":{"Line":3}},{"line":50,"address":[6523632],"length":1,"stats":{"Line":1}},{"line":51,"address":[5914061,5913967,5914132],"length":1,"stats":{"Line":3}},{"line":52,"address":[16736983],"length":1,"stats":{"Line":1}},{"line":53,"address":[6523973],"length":1,"stats":{"Line":1}},{"line":54,"address":[14287736,14287567,14287665],"length":1,"stats":{"Line":3}},{"line":55,"address":[10702779],"length":1,"stats":{"Line":1}},{"line":56,"address":[13184989],"length":1,"stats":{"Line":1}},{"line":57,"address":[16737515,16737584,16737417],"length":1,"stats":{"Line":3}},{"line":58,"address":[14644275],"length":1,"stats":{"Line":1}},{"line":59,"address":[13185285],"length":1,"stats":{"Line":1}},{"line":61,"address":[14643103],"length":1,"stats":{"Line":1}},{"line":66,"address":[10697295,10697650,10697136,10697772,10697800,10697171],"length":1,"stats":{"Line":4}},{"line":67,"address":[10698219,10697441,10697981,10697567,10697372,10697264,10698044,10697694,10698119],"length":1,"stats":{"Line":8}},{"line":68,"address":[21209499,21209476,21209545,21209428,21209874],"length":1,"stats":{"Line":2}},{"line":69,"address":[14282481,14282551,14282497,14282719],"length":1,"stats":{"Line":2}},{"line":70,"address":[10698029,10697322,10697610,10697831,10697721],"length":1,"stats":{"Line":3}},{"line":71,"address":[14283153,14283068,14283648,14283670],"length":1,"stats":{"Line":1}},{"line":73,"address":[6519836],"length":1,"stats":{"Line":1}},{"line":75,"address":[5912272,5911916,5910363,5910784,5912200],"length":1,"stats":{"Line":3}},{"line":76,"address":[14640150,14640329,14640261],"length":1,"stats":{"Line":3}},{"line":77,"address":[14640405],"length":1,"stats":{"Line":1}},{"line":78,"address":[21211439,21211352,21211507],"length":1,"stats":{"Line":3}},{"line":79,"address":[14640696],"length":1,"stats":{"Line":1}},{"line":80,"address":[10699547,10699631],"length":1,"stats":{"Line":2}},{"line":81,"address":[14284615,14284699,14284767],"length":1,"stats":{"Line":3}},{"line":82,"address":[6521282],"length":1,"stats":{"Line":1}},{"line":83,"address":[10699844],"length":1,"stats":{"Line":1}},{"line":84,"address":[14641081,14641198],"length":1,"stats":{"Line":2}},{"line":85,"address":[5911849],"length":1,"stats":{"Line":1}},{"line":86,"address":[5911879],"length":1,"stats":{"Line":1}},{"line":90,"address":[16733048],"length":1,"stats":{"Line":1}},{"line":94,"address":[10710396,10709927,10710274,10709811,10710424,10709776],"length":1,"stats":{"Line":8}},{"line":95,"address":[14295175,14295644,14295049,14295716,14295981,14295581,14294980,14294872,14295294,14295810],"length":1,"stats":{"Line":16}},{"line":96,"address":[6531507,6531874,6531484,6531436,6531553],"length":1,"stats":{"Line":4}},{"line":97,"address":[10710207,10710137,10710367,10710153],"length":1,"stats":{"Line":4}},{"line":98,"address":[5921964,5921861,5921490,5922154,5921778],"length":1,"stats":{"Line":7}},{"line":99,"address":[6532480,6532188,6532267,6532502],"length":1,"stats":{"Line":2}},{"line":100,"address":[24704734],"length":1,"stats":{"Line":2}},{"line":104,"address":[14645715,14645823,14645680,14646050,14646162],"length":1,"stats":{"Line":8}},{"line":105,"address":[14289676,14289568,14289743,14290360,14289870,14290125,14290188,14290263],"length":1,"stats":{"Line":14}},{"line":106,"address":[6526132,6526412,6526180,6526247,6526196],"length":1,"stats":{"Line":4}},{"line":107,"address":[5874564],"length":1,"stats":{"Line":8}},{"line":108,"address":[14648614,14648592,14646545,14646460],"length":1,"stats":{"Line":2}},{"line":110,"address":[16740057],"length":1,"stats":{"Line":2}},{"line":112,"address":[10705631,10707300,10707016,10705840,10707372],"length":1,"stats":{"Line":6}},{"line":113,"address":[10705878,10705989,10706057],"length":1,"stats":{"Line":6}},{"line":114,"address":[10706133],"length":1,"stats":{"Line":2}},{"line":115,"address":[5918092,5918243,5918175],"length":1,"stats":{"Line":6}},{"line":116,"address":[14291400],"length":1,"stats":{"Line":2}},{"line":117,"address":[13188735,13188651],"length":1,"stats":{"Line":4}},{"line":118,"address":[24700359,24700511,24700443],"length":1,"stats":{"Line":6}},{"line":119,"address":[16741266],"length":1,"stats":{"Line":2}},{"line":120,"address":[14291748],"length":1,"stats":{"Line":2}},{"line":121,"address":[10706809,10706926],"length":1,"stats":{"Line":4}},{"line":122,"address":[6528417],"length":1,"stats":{"Line":2}},{"line":123,"address":[16741507],"length":1,"stats":{"Line":2}},{"line":127,"address":[6527157],"length":1,"stats":{"Line":2}},{"line":139,"address":[13202304,13202472,13202339,13203208,13203086,13203258],"length":1,"stats":{"Line":49}},{"line":146,"address":[10720404,10721060,10720381,10720450,10720333],"length":1,"stats":{"Line":25}},{"line":147,"address":[13202674],"length":1,"stats":{"Line":10}},{"line":148,"address":[10720565],"length":1,"stats":{"Line":15}},{"line":149,"address":[13202777],"length":1,"stats":{"Line":10}},{"line":150,"address":[10720717,10720668,10720525,10721038,10720645],"length":1,"stats":{"Line":25}},{"line":151,"address":[21232869,21233099,21232939,21232885],"length":1,"stats":{"Line":30}},{"line":152,"address":[14306089,14306287,14305957,14305854,14305299],"length":1,"stats":{"Line":38}},{"line":153,"address":[21233549,21233776,21233798,21233470],"length":1,"stats":{"Line":6}},{"line":154,"address":[6543072],"length":1,"stats":{"Line":8}},{"line":158,"address":[13261584,13261592],"length":1,"stats":{"Line":8}},{"line":159,"address":[6535904,6536761,6536012,6536334,6536207,6536686,6536877,6536081,6536623],"length":1,"stats":{"Line":19}},{"line":160,"address":[24708739,24708292,24708409,24708340,24708363],"length":1,"stats":{"Line":6}},{"line":161,"address":[24708704,24708535,24708465,24708481],"length":1,"stats":{"Line":6}},{"line":162,"address":[16749528,16749417,16749727,16749018,16749306],"length":1,"stats":{"Line":11}},{"line":163,"address":[24709054,24711030,24711008,24709149],"length":1,"stats":{"Line":2}},{"line":165,"address":[21227710],"length":1,"stats":{"Line":2}},{"line":166,"address":[10716498,10715642],"length":1,"stats":{"Line":4}},{"line":167,"address":[14300875,14300652,14300807],"length":1,"stats":{"Line":6}},{"line":168,"address":[14300955],"length":1,"stats":{"Line":2}},{"line":169,"address":[16750553],"length":1,"stats":{"Line":2}},{"line":170,"address":[13198291],"length":1,"stats":{"Line":2}},{"line":171,"address":[5927555,5927718,5927649],"length":1,"stats":{"Line":6}},{"line":172,"address":[10716388],"length":1,"stats":{"Line":2}},{"line":173,"address":[10716422],"length":1,"stats":{"Line":2}},{"line":175,"address":[24709514],"length":1,"stats":{"Line":1}},{"line":180,"address":[10711802,10711283,10711248,10711425,10711689],"length":1,"stats":{"Line":4}},{"line":181,"address":[13193909,13194166,13194304,13194229,13194420,13193570,13193782],"length":1,"stats":{"Line":6}},{"line":182,"address":[21223603],"length":1,"stats":{"Line":1}},{"line":183,"address":[21223876,21223558,21223643,21223659,21223710],"length":1,"stats":{"Line":2}},{"line":184,"address":[16746566,16746177,16746367,16745980,16746288],"length":1,"stats":{"Line":4}},{"line":185,"address":[5925488,5925510,5923577,5923648],"length":1,"stats":{"Line":1}},{"line":187,"address":[14297438],"length":1,"stats":{"Line":1}},{"line":188,"address":[16747879,16747018],"length":1,"stats":{"Line":2}},{"line":189,"address":[13194703,13194858,13194926],"length":1,"stats":{"Line":3}},{"line":190,"address":[16747358],"length":1,"stats":{"Line":1}},{"line":191,"address":[6534348],"length":1,"stats":{"Line":1}},{"line":192,"address":[24706758],"length":1,"stats":{"Line":1}},{"line":193,"address":[5924481,5924646,5924575],"length":1,"stats":{"Line":3}},{"line":194,"address":[24707033],"length":1,"stats":{"Line":1}},{"line":195,"address":[6534747],"length":1,"stats":{"Line":1}},{"line":197,"address":[10712573],"length":1,"stats":{"Line":1}},{"line":202,"address":[13199667,13200002,13199775,13200114,13199632],"length":1,"stats":{"Line":8}},{"line":203,"address":[21229664,21230284,21229966,21230221,21230456,21229839,21230359,21229772],"length":1,"stats":{"Line":14}},{"line":204,"address":[14302727,14302892,14302660,14302676,14302612],"length":1,"stats":{"Line":4}},{"line":205,"address":[13199802,13199962,13200151,13200073,13200349],"length":1,"stats":{"Line":6}},{"line":206,"address":[16752764,16754416,16752849,16754438],"length":1,"stats":{"Line":2}},{"line":208,"address":[14303481],"length":1,"stats":{"Line":2}},{"line":210,"address":[5929923,5931369,5930352,5931297,5931085],"length":1,"stats":{"Line":6}},{"line":211,"address":[24712825,24712646,24712757],"length":1,"stats":{"Line":6}},{"line":212,"address":[14304079],"length":1,"stats":{"Line":2}},{"line":213,"address":[24712946,24713030],"length":1,"stats":{"Line":4}},{"line":214,"address":[6540718,6540799],"length":1,"stats":{"Line":4}},{"line":215,"address":[10719487,10719419,10719335],"length":1,"stats":{"Line":6}},{"line":216,"address":[16754034],"length":1,"stats":{"Line":2}},{"line":217,"address":[14660740],"length":1,"stats":{"Line":2}},{"line":221,"address":[5929953],"length":1,"stats":{"Line":2}},{"line":233,"address":[16788928,16788963,16789943,16790065,16789112,16790176],"length":1,"stats":{"Line":12}},{"line":240,"address":[13236797,13236868,13236914,13236845,13237802],"length":1,"stats":{"Line":7}},{"line":241,"address":[10754817,10755594,10754866,10754794],"length":1,"stats":{"Line":7}},{"line":242,"address":[24748706],"length":1,"stats":{"Line":4}},{"line":243,"address":[6576453],"length":1,"stats":{"Line":3}},{"line":244,"address":[21267121,21267668,21267144,21267037,21267190],"length":1,"stats":{"Line":9}},{"line":245,"address":[14340126,14340519,14340198,14340149],"length":1,"stats":{"Line":6}},{"line":246,"address":[6576820,6576980,6576750,6576766],"length":1,"stats":{"Line":7}},{"line":247,"address":[16790014,16790405,16789139,16789911,16790207],"length":1,"stats":{"Line":14}},{"line":248,"address":[14697440,14697219,14697140,14697462],"length":1,"stats":{"Line":4}},{"line":249,"address":[21268262],"length":1,"stats":{"Line":2}},{"line":253,"address":[10748178,10747699,10747823,10748329,10748301,10747664],"length":1,"stats":{"Line":4}},{"line":254,"address":[6569264,6570237,6569983,6569694,6569372,6569567,6570121,6569441,6570046],"length":1,"stats":{"Line":8}},{"line":255,"address":[21259956,21260073,21260027,21260403,21260004],"length":1,"stats":{"Line":2}},{"line":256,"address":[13230279,13230225,13230448,13230209],"length":1,"stats":{"Line":2}},{"line":257,"address":[5958250,5958733,5958538,5958924,5958629],"length":1,"stats":{"Line":4}},{"line":258,"address":[21263216,21260813,21260718,21263238],"length":1,"stats":{"Line":1}},{"line":260,"address":[14333962],"length":1,"stats":{"Line":1}},{"line":261,"address":[5959366,5960521],"length":1,"stats":{"Line":2}},{"line":262,"address":[21261308,21261376,21261153],"length":1,"stats":{"Line":3}},{"line":263,"address":[10749485,10749360],"length":1,"stats":{"Line":2}},{"line":264,"address":[5959898],"length":1,"stats":{"Line":1}},{"line":265,"address":[14690811],"length":1,"stats":{"Line":1}},{"line":266,"address":[10749799,10749870,10749701],"length":1,"stats":{"Line":3}},{"line":267,"address":[16784417],"length":1,"stats":{"Line":1}},{"line":268,"address":[14691116,14691214,14691283],"length":1,"stats":{"Line":3}},{"line":269,"address":[24743894],"length":1,"stats":{"Line":1}},{"line":270,"address":[13232312],"length":1,"stats":{"Line":1}},{"line":272,"address":[13231279],"length":1,"stats":{"Line":1}},{"line":277,"address":[14330060,14330088,14329459,14329424,14329938,14329583],"length":1,"stats":{"Line":4}},{"line":278,"address":[6567003,6566828,6566048,6566351,6566765,6566156,6566225,6566478,6566903],"length":1,"stats":{"Line":8}},{"line":279,"address":[5955277,5955188,5955140,5955207,5955559],"length":1,"stats":{"Line":2}},{"line":280,"address":[14329809,14329863,14330031,14329793],"length":1,"stats":{"Line":2}},{"line":281,"address":[14330009,14329610,14329898,14330119,14330317],"length":1,"stats":{"Line":3}},{"line":282,"address":[6568896,6566876,6568918,6566961],"length":1,"stats":{"Line":1}},{"line":284,"address":[10745676],"length":1,"stats":{"Line":1}},{"line":286,"address":[14332380,14330960,14332308,14330754,14332046],"length":1,"stats":{"Line":3}},{"line":287,"address":[14687222,14687401,14687333],"length":1,"stats":{"Line":3}},{"line":288,"address":[24740063,24740180],"length":1,"stats":{"Line":2}},{"line":289,"address":[5957129],"length":1,"stats":{"Line":1}},{"line":290,"address":[14687808,14687724],"length":1,"stats":{"Line":2}},{"line":291,"address":[14687968,14687900,14687816],"length":1,"stats":{"Line":3}},{"line":292,"address":[13228963],"length":1,"stats":{"Line":1}},{"line":293,"address":[16781355,16781508,16781442],"length":1,"stats":{"Line":3}},{"line":294,"address":[14688199],"length":1,"stats":{"Line":1}},{"line":295,"address":[16781561],"length":1,"stats":{"Line":1}},{"line":299,"address":[16780360],"length":1,"stats":{"Line":1}},{"line":303,"address":[13233679,13233906,13233571,13233536,13234018],"length":1,"stats":{"Line":4}},{"line":304,"address":[24745439,24745959,24745566,24745264,24746056,24745884,24745821,24745372],"length":1,"stats":{"Line":7}},{"line":305,"address":[13233780,13233996,13233831,13233764,13233716],"length":1,"stats":{"Line":2}},{"line":306,"address":[16786058,16786329,16786218,16786407,16786605],"length":1,"stats":{"Line":4}},{"line":307,"address":[24746017,24745932,24746534,24746512],"length":1,"stats":{"Line":1}},{"line":309,"address":[5962673],"length":1,"stats":{"Line":1}},{"line":311,"address":[24747838,24746752,24746303,24748100,24748172],"length":1,"stats":{"Line":3}},{"line":312,"address":[21265273,21265205,21265094],"length":1,"stats":{"Line":3}},{"line":313,"address":[6574836,6574719],"length":1,"stats":{"Line":2}},{"line":314,"address":[14338425],"length":1,"stats":{"Line":1}},{"line":315,"address":[24747292,24747376],"length":1,"stats":{"Line":2}},{"line":316,"address":[14338720,14338568,14338652],"length":1,"stats":{"Line":3}},{"line":317,"address":[21265859],"length":1,"stats":{"Line":1}},{"line":318,"address":[21265986,21265899,21266052],"length":1,"stats":{"Line":3}},{"line":319,"address":[13236151],"length":1,"stats":{"Line":1}},{"line":320,"address":[14338985],"length":1,"stats":{"Line":1}},{"line":324,"address":[6574037],"length":1,"stats":{"Line":1}},{"line":336,"address":[6593080,6591568,6591768,6592958,6591603,6593249],"length":1,"stats":{"Line":11}},{"line":343,"address":[10770333,10770381,10770450,10771755,10770404],"length":1,"stats":{"Line":6}},{"line":344,"address":[14355505,14355554,14356702,14355482],"length":1,"stats":{"Line":6}},{"line":345,"address":[14711834,14711857,14712894,14711906],"length":1,"stats":{"Line":6}},{"line":346,"address":[6592226],"length":1,"stats":{"Line":3}},{"line":347,"address":[14355797],"length":1,"stats":{"Line":3}},{"line":348,"address":[16805385],"length":1,"stats":{"Line":3}},{"line":349,"address":[14711981,14712205,14712872,14713568,14712156,14713580,14712101],"length":1,"stats":{"Line":6}},{"line":350,"address":[21283212,21284496,21283739,21283157,21284508,21283261],"length":1,"stats":{"Line":6}},{"line":351,"address":[21283340,21283389,21283710,21283317],"length":1,"stats":{"Line":6}},{"line":352,"address":[6592891,6593051,6592821,6592837],"length":1,"stats":{"Line":6}},{"line":353,"address":[5981476,5981666,5981146,5980067,5981229],"length":1,"stats":{"Line":13}},{"line":354,"address":[10772432,10772454,10772148,10772069],"length":1,"stats":{"Line":4}},{"line":355,"address":[13254471],"length":1,"stats":{"Line":4}},{"line":359,"address":[14365000,14364992],"length":1,"stats":{"Line":8}},{"line":360,"address":[16797500,16796817,16797437,16796886,16797575,16797139,16797012,16797691,16796703],"length":1,"stats":{"Line":16}},{"line":361,"address":[13244425,13244473,13244872,13244542,13244496],"length":1,"stats":{"Line":4}},{"line":362,"address":[6584133,6583894,6583910,6583964],"length":1,"stats":{"Line":4}},{"line":363,"address":[5972690,5972586,5972495,5972890,5972204],"length":1,"stats":{"Line":8}},{"line":364,"address":[14704220,14707248,14707270,14704315],"length":1,"stats":{"Line":2}},{"line":366,"address":[14704584],"length":1,"stats":{"Line":2}},{"line":367,"address":[16799458,16797943],"length":1,"stats":{"Line":4}},{"line":368,"address":[21275706,21275774,21275551],"length":1,"stats":{"Line":6}},{"line":369,"address":[13245934,13246059],"length":1,"stats":{"Line":4}},{"line":370,"address":[21276184,21276059],"length":1,"stats":{"Line":4}},{"line":371,"address":[14349144],"length":1,"stats":{"Line":2}},{"line":372,"address":[16798742],"length":1,"stats":{"Line":2}},{"line":373,"address":[5974189],"length":1,"stats":{"Line":2}},{"line":374,"address":[10764391],"length":1,"stats":{"Line":2}},{"line":375,"address":[13248043,13246654,13248016],"length":1,"stats":{"Line":2}},{"line":376,"address":[5975696,5975708,5974394],"length":1,"stats":{"Line":2}},{"line":377,"address":[16799060],"length":1,"stats":{"Line":2}},{"line":378,"address":[10764578,10765632,10765659],"length":1,"stats":{"Line":2}},{"line":379,"address":[21276701,21277888,21277900],"length":1,"stats":{"Line":2}},{"line":380,"address":[21276897,21276728,21276826],"length":1,"stats":{"Line":6}},{"line":381,"address":[24758612],"length":1,"stats":{"Line":2}},{"line":382,"address":[13247030],"length":1,"stats":{"Line":2}},{"line":384,"address":[13245677],"length":1,"stats":{"Line":1}},{"line":389,"address":[6578677,6578140,6577952,6577987,6578649,6578524],"length":1,"stats":{"Line":8}},{"line":390,"address":[16791627,16791379,16791165,16792326,16791980,16792152,16791917,16792055,16791505],"length":1,"stats":{"Line":14}},{"line":391,"address":[21268846],"length":1,"stats":{"Line":2}},{"line":392,"address":[24750605,24750975,24750497,24750651,24750582],"length":1,"stats":{"Line":4}},{"line":393,"address":[13239161,13239107,13239091,13239324],"length":1,"stats":{"Line":4}},{"line":394,"address":[5967250,5967353,5967546,5967164,5966839],"length":1,"stats":{"Line":7}},{"line":395,"address":[21269681,21269904,21269926,21269596],"length":1,"stats":{"Line":2}},{"line":396,"address":[13239908],"length":1,"stats":{"Line":2}},{"line":400,"address":[5968303,5968753,5968638,5968144,5968179,5968781],"length":1,"stats":{"Line":4}},{"line":401,"address":[21270380,21271227,21271127,21271052,21270989,21270702,21270272,21270449,21270575],"length":1,"stats":{"Line":8}},{"line":402,"address":[16793218,16792772,16792820,16792843,16792889],"length":1,"stats":{"Line":2}},{"line":403,"address":[13240663,13240593,13240609,13240831],"length":1,"stats":{"Line":2}},{"line":404,"address":[21270330,21270839,21271037,21270729,21270618],"length":1,"stats":{"Line":4}},{"line":405,"address":[10761376,10759089,10759004,10761398],"length":1,"stats":{"Line":1}},{"line":407,"address":[21271372],"length":1,"stats":{"Line":1}},{"line":409,"address":[21271680,21273453,21273048,21271474,21273381],"length":1,"stats":{"Line":3}},{"line":410,"address":[5970069,5969894,5970001],"length":1,"stats":{"Line":3}},{"line":411,"address":[14701077,14701194],"length":1,"stats":{"Line":2}},{"line":412,"address":[14701279,14701396],"length":1,"stats":{"Line":2}},{"line":413,"address":[10760281],"length":1,"stats":{"Line":1}},{"line":414,"address":[21272428,21272512],"length":1,"stats":{"Line":2}},{"line":415,"address":[21272601,21272520],"length":1,"stats":{"Line":2}},{"line":416,"address":[14701713],"length":1,"stats":{"Line":1}},{"line":417,"address":[24755568,24754381,24755595],"length":1,"stats":{"Line":1}},{"line":418,"address":[14703184,14703196,14701816],"length":1,"stats":{"Line":1}},{"line":419,"address":[14345624],"length":1,"stats":{"Line":1}},{"line":420,"address":[13242862,13243792,13243819],"length":1,"stats":{"Line":1}},{"line":421,"address":[10761936,10760713,10761948],"length":1,"stats":{"Line":1}},{"line":422,"address":[6582334,6582217],"length":1,"stats":{"Line":2}},{"line":423,"address":[10760881],"length":1,"stats":{"Line":1}},{"line":424,"address":[13243091],"length":1,"stats":{"Line":1}},{"line":428,"address":[14700632],"length":1,"stats":{"Line":1}},{"line":432,"address":[10766288,10766431,10766658,10766770,10766323],"length":1,"stats":{"Line":4}},{"line":433,"address":[13249133,13249196,13248576,13248878,13248751,13249271,13248684,13249368],"length":1,"stats":{"Line":7}},{"line":434,"address":[16801060,16801111,16801276,16801044,16800996],"length":1,"stats":{"Line":2}},{"line":435,"address":[24760521,24760797,24760599,24760250,24760410],"length":1,"stats":{"Line":4}},{"line":436,"address":[13249846,13249329,13249244,13249824],"length":1,"stats":{"Line":1}},{"line":438,"address":[13249513],"length":1,"stats":{"Line":1}},{"line":440,"address":[21279984,21281685,21281352,21281757,21279535],"length":1,"stats":{"Line":3}},{"line":441,"address":[16802633,16802454,16802565],"length":1,"stats":{"Line":3}},{"line":442,"address":[14709381,14709498],"length":1,"stats":{"Line":2}},{"line":443,"address":[10768383,10768500],"length":1,"stats":{"Line":2}},{"line":444,"address":[13250761],"length":1,"stats":{"Line":1}},{"line":445,"address":[10768720,10768636],"length":1,"stats":{"Line":2}},{"line":446,"address":[10768728,10768809],"length":1,"stats":{"Line":2}},{"line":447,"address":[21280913],"length":1,"stats":{"Line":1}},{"line":448,"address":[14354891,14354864,14353869],"length":1,"stats":{"Line":1}},{"line":449,"address":[10770060,10770048,10768920],"length":1,"stats":{"Line":1}},{"line":450,"address":[21281048],"length":1,"stats":{"Line":1}},{"line":451,"address":[10769707,10769680,10768990],"length":1,"stats":{"Line":1}},{"line":452,"address":[16804380,16803545,16804368],"length":1,"stats":{"Line":1}},{"line":453,"address":[5978557,5978670],"length":1,"stats":{"Line":2}},{"line":454,"address":[14710385],"length":1,"stats":{"Line":1}},{"line":455,"address":[5978719],"length":1,"stats":{"Line":1}},{"line":459,"address":[16802021],"length":1,"stats":{"Line":1}},{"line":471,"address":[10696205,10694515,10694480,10694680,10696040,10695918],"length":1,"stats":{"Line":12}},{"line":478,"address":[14279764,14281159,14279810,14279693,14279741],"length":1,"stats":{"Line":6}},{"line":479,"address":[16729441,16729418,16729490,16730679],"length":1,"stats":{"Line":6}},{"line":480,"address":[10695010],"length":1,"stats":{"Line":3}},{"line":481,"address":[6516549],"length":1,"stats":{"Line":2}},{"line":482,"address":[13177297,13177349,13177398,13178305,13179248,13177213,13179260],"length":1,"stats":{"Line":5}},{"line":483,"address":[5907294,5908736,5907339,5908748,5908072,5907412],"length":1,"stats":{"Line":6}},{"line":484,"address":[13177608,13177657,13177706,13178244,13178976,13178988],"length":1,"stats":{"Line":6}},{"line":485,"address":[16730176,16730106],"length":1,"stats":{"Line":6}},{"line":486,"address":[14636925,14636856,14636879,14636808,14637246],"length":1,"stats":{"Line":6}},{"line":487,"address":[24689589,24689803,24689573,24689643],"length":1,"stats":{"Line":6}},{"line":488,"address":[5874916],"length":1,"stats":{"Line":11}},{"line":489,"address":[14281552,14281473,14281808,14281830],"length":1,"stats":{"Line":2}},{"line":490,"address":[10696723],"length":1,"stats":{"Line":2}},{"line":494,"address":[16813616,16813624],"length":1,"stats":{"Line":8}},{"line":495,"address":[5901710,5901017,5901314,5901433,5901856,5901769,5901131,5901196,5901952],"length":1,"stats":{"Line":16}},{"line":496,"address":[21200947,21200995,21201394,21201018,21201064],"length":1,"stats":{"Line":4}},{"line":497,"address":[5901268,5901476,5901338,5901256],"length":1,"stats":{"Line":4}},{"line":498,"address":[5901369,5901078,5901460,5901564,5901758],"length":1,"stats":{"Line":8}},{"line":499,"address":[10689711,10691990,10689616,10691968],"length":1,"stats":{"Line":2}},{"line":501,"address":[5902169],"length":1,"stats":{"Line":2}},{"line":502,"address":[5903474,5902200],"length":1,"stats":{"Line":4}},{"line":503,"address":[13172227,13172450,13172382],"length":1,"stats":{"Line":6}},{"line":504,"address":[6511951,6511826],"length":1,"stats":{"Line":4}},{"line":505,"address":[13172735],"length":1,"stats":{"Line":2}},{"line":506,"address":[14631805],"length":1,"stats":{"Line":2}},{"line":507,"address":[24684487],"length":1,"stats":{"Line":2}},{"line":508,"address":[14633568,14633593,14631982],"length":1,"stats":{"Line":4}},{"line":509,"address":[14275785],"length":1,"stats":{"Line":2}},{"line":510,"address":[13174859,13174832,13173031],"length":1,"stats":{"Line":2}},{"line":511,"address":[6514080,6512354,6514092],"length":1,"stats":{"Line":2}},{"line":512,"address":[16725437],"length":1,"stats":{"Line":2}},{"line":513,"address":[13173131,13174384,13174411],"length":1,"stats":{"Line":2}},{"line":514,"address":[24684774,24686352,24686364],"length":1,"stats":{"Line":2}},{"line":515,"address":[10691009],"length":1,"stats":{"Line":2}},{"line":516,"address":[16725579,16725677,16725748],"length":1,"stats":{"Line":6}},{"line":517,"address":[21203335],"length":1,"stats":{"Line":2}},{"line":518,"address":[13173449],"length":1,"stats":{"Line":2}},{"line":520,"address":[10690097],"length":1,"stats":{"Line":1}},{"line":525,"address":[6509241,6508544,6508579,6508732,6509269,6509116],"length":1,"stats":{"Line":8}},{"line":526,"address":[24681773,24681483,24681021,24682008,24681361,24682182,24681235,24681836,24681911],"length":1,"stats":{"Line":14}},{"line":527,"address":[21199438],"length":1,"stats":{"Line":2}},{"line":528,"address":[24681197,24681243,24681567,24681089,24681174],"length":1,"stats":{"Line":4}},{"line":529,"address":[14628777,14628723,14628940,14628707],"length":1,"stats":{"Line":4}},{"line":530,"address":[5821463],"length":1,"stats":{"Line":8}},{"line":531,"address":[10688400,10688092,10688422,10688177],"length":1,"stats":{"Line":2}},{"line":532,"address":[10688324],"length":1,"stats":{"Line":2}},{"line":536,"address":[14634186,14634844,14634661,14634787,14634016,14634051],"length":1,"stats":{"Line":8}},{"line":537,"address":[14277931,14278108,14278940,14278805,14279034,14278039,14278236,14278868,14279208,14278484,14278362],"length":1,"stats":{"Line":18}},{"line":538,"address":[21205167,21205119,21205190,21205236,21205718],"length":1,"stats":{"Line":4}},{"line":539,"address":[13175395,13175372,13175769,13175444],"length":1,"stats":{"Line":4}},{"line":540,"address":[10693558,10693340,10693324,10693394],"length":1,"stats":{"Line":4}},{"line":541,"address":[5817847],"length":1,"stats":{"Line":8}},{"line":542,"address":[13176416,13176116,13176195,13176438],"length":1,"stats":{"Line":2}},{"line":543,"address":[24687958],"length":1,"stats":{"Line":2}},{"line":547,"address":[13261160,13261152],"length":1,"stats":{"Line":8}},{"line":548,"address":[21192381,21192619,21191664,21192444,21191772,21191841,21192094,21192519,21191967],"length":1,"stats":{"Line":16}},{"line":549,"address":[14264729,14265058,14264660,14264683,14264612],"length":1,"stats":{"Line":4}},{"line":550,"address":[10679825,10680047,10679809,10679879],"length":1,"stats":{"Line":4}},{"line":551,"address":[14264890,14264602,14265111,14265309,14265001],"length":1,"stats":{"Line":7}},{"line":552,"address":[6501868,6502448,6501953,6502470],"length":1,"stats":{"Line":2}},{"line":554,"address":[21192764],"length":1,"stats":{"Line":2}},{"line":556,"address":[16717193,16715298,16715744,16717243,16716918],"length":1,"stats":{"Line":6}},{"line":557,"address":[14622625,14622454,14622557],"length":1,"stats":{"Line":6}},{"line":558,"address":[13163671,13163788],"length":1,"stats":{"Line":4}},{"line":559,"address":[10681697],"length":1,"stats":{"Line":2}},{"line":560,"address":[6503304,6503220],"length":1,"stats":{"Line":4}},{"line":561,"address":[10681840],"length":1,"stats":{"Line":2}},{"line":562,"address":[13165305,13164092,13165280],"length":1,"stats":{"Line":4}},{"line":563,"address":[6503420],"length":1,"stats":{"Line":2}},{"line":564,"address":[6504256,6503458,6504283],"length":1,"stats":{"Line":2}},{"line":565,"address":[6504220,6503485,6504208],"length":1,"stats":{"Line":2}},{"line":566,"address":[10682045],"length":1,"stats":{"Line":2}},{"line":567,"address":[5894547,5895195,5895168],"length":1,"stats":{"Line":2}},{"line":568,"address":[14623310,14624496,14624508],"length":1,"stats":{"Line":2}},{"line":569,"address":[10682142],"length":1,"stats":{"Line":2}},{"line":570,"address":[16716709,16716824],"length":1,"stats":{"Line":4}},{"line":571,"address":[13164491],"length":1,"stats":{"Line":2}},{"line":572,"address":[6503821],"length":1,"stats":{"Line":2}},{"line":576,"address":[21192920],"length":1,"stats":{"Line":2}},{"line":580,"address":[10683379,10683344,10683714,10683826,10683487],"length":1,"stats":{"Line":4}},{"line":581,"address":[13165632,13165934,13166189,13166327,13165740,13165807,13166252,13166424],"length":1,"stats":{"Line":7}},{"line":582,"address":[13165980,13165764,13165700,13165748,13165815],"length":1,"stats":{"Line":2}},{"line":583,"address":[5877956],"length":1,"stats":{"Line":3}},{"line":584,"address":[13166385,13166880,13166300,13166902],"length":1,"stats":{"Line":1}},{"line":586,"address":[6505865],"length":1,"stats":{"Line":1}},{"line":588,"address":[24680185,24680235,24678736,24678287,24679910],"length":1,"stats":{"Line":3}},{"line":589,"address":[5897110,5897209,5897277],"length":1,"stats":{"Line":3}},{"line":590,"address":[14626423,14626540],"length":1,"stats":{"Line":2}},{"line":591,"address":[24679217],"length":1,"stats":{"Line":1}},{"line":592,"address":[24679352,24679268],"length":1,"stats":{"Line":2}},{"line":593,"address":[14626768],"length":1,"stats":{"Line":1}},{"line":594,"address":[5898793,5897760,5898768],"length":1,"stats":{"Line":1}},{"line":595,"address":[21197772],"length":1,"stats":{"Line":1}},{"line":596,"address":[5898971,5897822,5898944],"length":1,"stats":{"Line":1}},{"line":597,"address":[16720992,16720269,16721004],"length":1,"stats":{"Line":1}},{"line":598,"address":[21197869],"length":1,"stats":{"Line":1}},{"line":599,"address":[10686512,10685811,10686539],"length":1,"stats":{"Line":1}},{"line":600,"address":[6508304,6507310,6508316],"length":1,"stats":{"Line":1}},{"line":601,"address":[6507342],"length":1,"stats":{"Line":1}},{"line":602,"address":[14270885,14271000],"length":1,"stats":{"Line":2}},{"line":603,"address":[21198139],"length":1,"stats":{"Line":1}},{"line":604,"address":[5898137],"length":1,"stats":{"Line":1}},{"line":608,"address":[14269525],"length":1,"stats":{"Line":1}},{"line":620,"address":[6197981,6197968],"length":1,"stats":{"Line":8}},{"line":627,"address":[6562205,6562253,6562276,6563346,6562322],"length":1,"stats":{"Line":4}},{"line":628,"address":[14682098],"length":1,"stats":{"Line":2}},{"line":629,"address":[13223141],"length":1,"stats":{"Line":2}},{"line":630,"address":[10741001],"length":1,"stats":{"Line":2}},{"line":631,"address":[21253133],"length":1,"stats":{"Line":2}},{"line":633,"address":[24734717,24734911,24735644,24734960,24735009,24736512,24736524],"length":1,"stats":{"Line":12}},{"line":634,"address":[10741273,10741345,10741296,10741823],"length":1,"stats":{"Line":6}},{"line":635,"address":[24735193,24735265,24735586,24735216],"length":1,"stats":{"Line":6}},{"line":636,"address":[14682959,14682729,14682799,14682745],"length":1,"stats":{"Line":6}},{"line":637,"address":[13471156],"length":1,"stats":{"Line":10}},{"line":638,"address":[14683388,14683467,14683680,14683702],"length":1,"stats":{"Line":3}},{"line":639,"address":[14683614],"length":1,"stats":{"Line":3}},{"line":643,"address":[14364656,14364664],"length":1,"stats":{"Line":8}},{"line":644,"address":[10734076,10734687,10734750,10734825,10734271,10734941,10734398,10733968,10734145],"length":1,"stats":{"Line":16}},{"line":645,"address":[5945272,5944919,5944852,5944989,5944900],"length":1,"stats":{"Line":4}},{"line":646,"address":[16768737,16768807,16768753,16768976],"length":1,"stats":{"Line":4}},{"line":647,"address":[10468884],"length":1,"stats":{"Line":8}},{"line":648,"address":[14322208,14319869,14319774,14322230],"length":1,"stats":{"Line":2}},{"line":650,"address":[24728948],"length":1,"stats":{"Line":2}},{"line":651,"address":[10736232,10735184],"length":1,"stats":{"Line":4}},{"line":652,"address":[14676421,14676576,14676644],"length":1,"stats":{"Line":6}},{"line":653,"address":[13217700],"length":1,"stats":{"Line":2}},{"line":654,"address":[10735570],"length":1,"stats":{"Line":2}},{"line":655,"address":[13217836],"length":1,"stats":{"Line":2}},{"line":656,"address":[13217923],"length":1,"stats":{"Line":2}},{"line":657,"address":[14320806],"length":1,"stats":{"Line":2}},{"line":658,"address":[24730843,24730816,24729668],"length":1,"stats":{"Line":6}},{"line":659,"address":[10737184,10735903,10737196],"length":1,"stats":{"Line":6}},{"line":660,"address":[16770556,16770627,16770458],"length":1,"stats":{"Line":6}},{"line":661,"address":[21248214],"length":1,"stats":{"Line":2}},{"line":662,"address":[10736152],"length":1,"stats":{"Line":2}},{"line":664,"address":[14320243],"length":1,"stats":{"Line":1}},{"line":669,"address":[13224928,13225090,13224963,13225730,13225794,13225604],"length":1,"stats":{"Line":4}},{"line":670,"address":[21256128,21255103,21255449,21255323,21255962,21256302,21254979,21255571,21255899,21255195,21256034],"length":1,"stats":{"Line":9}},{"line":671,"address":[24736899,24736807,24736743,24736822,24737381],"length":1,"stats":{"Line":2}},{"line":672,"address":[24736978,24736751,24737027,24737352,24736955],"length":1,"stats":{"Line":2}},{"line":673,"address":[14328501,14327943,14328337,14328267,14328283],"length":1,"stats":{"Line":2}},{"line":674,"address":[14328827,14327917,14328478,14328625,14328372],"length":1,"stats":{"Line":3}},{"line":675,"address":[16778442,16778736,16778521,16778758],"length":1,"stats":{"Line":1}},{"line":676,"address":[21256236],"length":1,"stats":{"Line":1}},{"line":680,"address":[10732460,10732997,10732844,10732969,10732307,10732272],"length":1,"stats":{"Line":8}},{"line":681,"address":[24726561,24726221,24727208,24726435,24727036,24726683,24727382,24726973,24727111],"length":1,"stats":{"Line":14}},{"line":682,"address":[6554014],"length":1,"stats":{"Line":2}},{"line":683,"address":[21245071,21244701,21244593,21244678,21244747],"length":1,"stats":{"Line":4}},{"line":684,"address":[24726569,24726515,24726499,24726732],"length":1,"stats":{"Line":4}},{"line":685,"address":[13214988,13214663,13215405,13215094,13215204],"length":1,"stats":{"Line":7}},{"line":686,"address":[6555094,6554764,6554849,6555072],"length":1,"stats":{"Line":2}},{"line":687,"address":[21245620],"length":1,"stats":{"Line":2}},{"line":691,"address":[6197960,6197952],"length":1,"stats":{"Line":4}},{"line":692,"address":[10738204,10737759,10738376,10737886,10738279,10737692,10737584,10738141],"length":1,"stats":{"Line":7}},{"line":693,"address":[6559124,6559172,6559239,6559188,6559404],"length":1,"stats":{"Line":2}},{"line":694,"address":[13469524],"length":1,"stats":{"Line":4}},{"line":695,"address":[16774592,16772865,16772780,16774614],"length":1,"stats":{"Line":1}},{"line":697,"address":[10738521],"length":1,"stats":{"Line":1}},{"line":699,"address":[14323599,14324712,14323808,14324949,14325021],"length":1,"stats":{"Line":3}},{"line":700,"address":[21250966,21251077,21251145],"length":1,"stats":{"Line":3}},{"line":701,"address":[24732911],"length":1,"stats":{"Line":1}},{"line":702,"address":[6560642,6560726],"length":1,"stats":{"Line":2}},{"line":703,"address":[14680462,14680543],"length":1,"stats":{"Line":2}},{"line":704,"address":[14680551],"length":1,"stats":{"Line":1}},{"line":705,"address":[10739428],"length":1,"stats":{"Line":1}},{"line":706,"address":[14324442,14325280,14325307],"length":1,"stats":{"Line":3}},{"line":707,"address":[10739493,10740476,10740464],"length":1,"stats":{"Line":3}},{"line":708,"address":[14324618,14324501],"length":1,"stats":{"Line":2}},{"line":709,"address":[14324637],"length":1,"stats":{"Line":1}},{"line":710,"address":[10739695],"length":1,"stats":{"Line":1}},{"line":714,"address":[21250773],"length":1,"stats":{"Line":1}},{"line":718,"address":[6551074,6551186,6550847,6550704,6550739],"length":1,"stats":{"Line":4}},{"line":719,"address":[24723136,24723928,24723831,24723311,24723244,24723693,24723756,24723438],"length":1,"stats":{"Line":7}},{"line":720,"address":[21241556,21241788,21241572,21241623,21241508],"length":1,"stats":{"Line":2}},{"line":721,"address":[16763930,16764477,16764090,16764279,16764201],"length":1,"stats":{"Line":4}},{"line":722,"address":[14671297,14671792,14671212,14671814],"length":1,"stats":{"Line":1}},{"line":724,"address":[21242377],"length":1,"stats":{"Line":1}},{"line":726,"address":[6552304,6553445,6553208,6551855,6553517],"length":1,"stats":{"Line":3}},{"line":727,"address":[16765577,16765398,16765509],"length":1,"stats":{"Line":3}},{"line":728,"address":[14316095],"length":1,"stats":{"Line":1}},{"line":729,"address":[10731170,10731254],"length":1,"stats":{"Line":2}},{"line":730,"address":[24725135,24725054],"length":1,"stats":{"Line":2}},{"line":731,"address":[24725143],"length":1,"stats":{"Line":1}},{"line":732,"address":[13213604],"length":1,"stats":{"Line":1}},{"line":733,"address":[14673264,14673291,14672666],"length":1,"stats":{"Line":3}},{"line":734,"address":[13214412,13213669,13214400],"length":1,"stats":{"Line":3}},{"line":735,"address":[10731525,10731642],"length":1,"stats":{"Line":2}},{"line":736,"address":[5942293],"length":1,"stats":{"Line":1}},{"line":737,"address":[14672895],"length":1,"stats":{"Line":1}},{"line":741,"address":[10730437],"length":1,"stats":{"Line":1}},{"line":753,"address":[14669387,14668544,14669510,14669589,14668579,14668720],"length":1,"stats":{"Line":10}},{"line":760,"address":[24722159,24721349,24721397,24721420,24721466],"length":1,"stats":{"Line":4}},{"line":761,"address":[14668922,14668989],"length":1,"stats":{"Line":5}},{"line":762,"address":[10727820,10727866,10727797,10728345,10727749],"length":1,"stats":{"Line":5}},{"line":763,"address":[13210121,13210098,13210170,13210492],"length":1,"stats":{"Line":5}},{"line":764,"address":[14313026,14313042,14313096,14313257],"length":1,"stats":{"Line":6}},{"line":765,"address":[5819668],"length":1,"stats":{"Line":10}},{"line":766,"address":[21241088,21240778,21241110,21240863],"length":1,"stats":{"Line":2}},{"line":767,"address":[24722706],"length":1,"stats":{"Line":2}},{"line":771,"address":[16814048,16814071],"length":1,"stats":{"Line":8}},{"line":775,"address":[14309844],"length":1,"stats":{"Line":2}},{"line":776,"address":[24718692],"length":1,"stats":{"Line":2}},{"line":777,"address":[14309986,14309932,14310152,14309799,14309916],"length":1,"stats":{"Line":4}},{"line":778,"address":[14666356,14666013,14666245,14666634,14666435],"length":1,"stats":{"Line":9}},{"line":779,"address":[24719289,24719378,24719910,24719888],"length":1,"stats":{"Line":3}},{"line":781,"address":[13207958],"length":1,"stats":{"Line":2}},{"line":783,"address":[21237980,21238432,21239427,21239377,21239189],"length":1,"stats":{"Line":7}},{"line":784,"address":[5937267,5937332,5937174],"length":1,"stats":{"Line":6}},{"line":785,"address":[21238681],"length":1,"stats":{"Line":2}},{"line":786,"address":[5937507,5937424,5937575],"length":1,"stats":{"Line":6}},{"line":787,"address":[10726810],"length":1,"stats":{"Line":2}},{"line":788,"address":[10726937,10727003,10726850],"length":1,"stats":{"Line":6}},{"line":789,"address":[5937798],"length":1,"stats":{"Line":2}},{"line":790,"address":[24720848],"length":1,"stats":{"Line":2}},{"line":794,"address":[24719730],"length":1,"stats":{"Line":3}},{"line":798,"address":[16756867,16756448,16756483,16756608,16756980],"length":1,"stats":{"Line":8}},{"line":802,"address":[24715949],"length":1,"stats":{"Line":3}},{"line":803,"address":[6543736,6543685,6543669,6543589,6543902],"length":1,"stats":{"Line":4}},{"line":804,"address":[5933291,5933491,5933657,5933582,5933848],"length":1,"stats":{"Line":10}},{"line":805,"address":[14308320,14307727,14307812,14308342],"length":1,"stats":{"Line":3}},{"line":807,"address":[13205202],"length":1,"stats":{"Line":3}},{"line":809,"address":[13205304,13206705,13206517,13206755,13205760],"length":1,"stats":{"Line":7}},{"line":810,"address":[6545256,6545191,6545094],"length":1,"stats":{"Line":6}},{"line":811,"address":[10723833],"length":1,"stats":{"Line":2}},{"line":812,"address":[14665239,14665084,14665171],"length":1,"stats":{"Line":6}},{"line":813,"address":[21236154],"length":1,"stats":{"Line":2}},{"line":814,"address":[21236194,21236281,21236347],"length":1,"stats":{"Line":6}},{"line":815,"address":[24718062],"length":1,"stats":{"Line":2}},{"line":816,"address":[16758832],"length":1,"stats":{"Line":2}},{"line":820,"address":[6544654],"length":1,"stats":{"Line":2}}],"covered":523,"coverable":523},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-db","tests","integration.rs"],"content":"//! Integration tests for GitForce database\n//!\n//! These tests use in-memory SQLite databases for testing.\n\nuse gitforce_db::Pool;\nuse gitforce_db::queries::{RepoQueries, UserQueries, RunnerQueries, PipelineQueries, PipelineRunQueries, JobQueries, EventQueries};\nuse gitforce_db::models::{Repository, User, Runner, RunnerType, Pipeline, PipelineRun, Job, Event};\nuse gitforce_common::PipelineId;\n\n#[tokio::test]\nasync fn test_database_in_memory_pool() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user\n let user = User::new(\"testuser\".to_string(), \"test@example.com\".to_string(), \"hash\".to_string());\n UserQueries::create(&pool, &user).await.unwrap();\n\n // Verify user can be retrieved\n let found = UserQueries::get(&pool, user.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().username, \"testuser\");\n}\n\n#[tokio::test]\nasync fn test_database_repository_crud() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user first\n let user = User::new(\"owner\".to_string(), \"owner@example.com\".to_string(), \"hash\".to_string());\n UserQueries::create(&pool, &user).await.unwrap();\n\n // Create repository\n let repo = Repository::new(\"test-repo\".to_string(), user.id, \"/git/test-repo\".to_string());\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // List repositories\n let repos = RepoQueries::list(&pool).await.unwrap();\n assert_eq!(repos.len(), 1);\n\n // Delete repository\n RepoQueries::delete(&pool, repo.id).await.unwrap();\n let repos = RepoQueries::list(&pool).await.unwrap();\n assert!(repos.is_empty());\n}\n\n#[tokio::test]\nasync fn test_database_runner_operations() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create runner\n let runner = Runner::new(\"test-runner\".to_string(), RunnerType::Docker, 4);\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // Verify runner\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"test-runner\");\n\n // Update status\n RunnerQueries::update_status(&pool, runner.id, \"offline\").await.unwrap();\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"offline\");\n}\n\n#[tokio::test]\nasync fn test_database_pipeline_with_dependencies() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create user and repo\n let user = User::new(\"owner\".to_string(), \"owner@example.com\".to_string(), \"hash\".to_string());\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = Repository::new(\"test-repo\".to_string(), user.id, \"/git/test-repo\".to_string());\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // Create pipeline\n let pipeline = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"CI Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n // Create pipeline run\n let run = PipelineRun::new(pipeline.id, repo.id, \"alice\".to_string(), \"abc123\".to_string());\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Create job\n let job = Job::new(run.id, \"build\".to_string());\n JobQueries::create(&pool, &job).await.unwrap();\n\n // Verify job\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"build\");\n\n // List jobs by run\n let jobs = JobQueries::list_by_run(&pool, run.id).await.unwrap();\n assert_eq!(jobs.len(), 1);\n}\n\n#[tokio::test]\nasync fn test_database_event_storage() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create event\n let event = Event::new(\n \"push.received\".to_string(),\n serde_json::json!({\"repo\": \"test\", \"branch\": \"main\"}),\n );\n EventQueries::create(&pool, &event).await.unwrap();\n\n // List events by type\n let events = EventQueries::list_by_type(&pool, \"push.received\", 10).await.unwrap();\n assert_eq!(events.len(), 1);\n\n // List recent events\n let recent = EventQueries::list_recent(&pool, 10).await.unwrap();\n assert_eq!(recent.len(), 1);\n}\n\n#[tokio::test]\nasync fn test_database_multiple_users() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Create multiple users\n for i in 0..5 {\n let user = User::new(\n format!(\"user{}\", i),\n format!(\"user{}@example.com\", i),\n \"hash\".to_string(),\n );\n UserQueries::create(&pool, &user).await.unwrap();\n }\n\n // List all users\n let users = UserQueries::list(&pool).await.unwrap();\n assert_eq!(users.len(), 5);\n}\n\n#[tokio::test]\nasync fn test_database_job_state_transitions() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Setup: user, repo, pipeline, run, job\n let user = User::new(\"owner\".to_string(), \"owner@example.com\".to_string(), \"hash\".to_string());\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = Repository::new(\"test-repo\".to_string(), user.id, \"/git/test-repo\".to_string());\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = PipelineRun::new(pipeline.id, repo.id, \"alice\".to_string(), \"abc123\".to_string());\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n let job = Job::new(run.id, \"build\".to_string());\n JobQueries::create(&pool, &job).await.unwrap();\n\n // Create runner for assignment\n let runner = Runner::new(\"runner\".to_string(), RunnerType::Docker, 2);\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // Update job status to running\n JobQueries::update_status(&pool, job.id, \"running\").await.unwrap();\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"running\");\n\n // Assign runner\n JobQueries::assign(&pool, job.id, runner.id).await.unwrap();\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert!(found.unwrap().runner_id.is_some());\n}\n\n#[tokio::test]\nasync fn test_database_pipeline_run_status_updates() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n // Setup\n let user = User::new(\"owner\".to_string(), \"owner@example.com\".to_string(), \"hash\".to_string());\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = Repository::new(\"test-repo\".to_string(), user.id, \"/git/test-repo\".to_string());\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"Test\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = PipelineRun::new(pipeline.id, repo.id, \"alice\".to_string(), \"abc123\".to_string());\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Update status through various states\n for status in &[\"pending\", \"running\", \"succeeded\"] {\n PipelineRunQueries::update_status(&pool, run.id, status).await.unwrap();\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap();\n assert_eq!(found.unwrap().status, *status);\n }\n}\n","traces":[{"line":1,"address":[5834080,5834093],"length":1,"stats":{"Line":2}},{"line":10,"address":[5862478,5862288,5862328,5862463,5862602,5862498,5863046,5865157,5862448,5862411],"length":1,"stats":{"Line":8}},{"line":11,"address":[5830832,5831283,5831277],"length":1,"stats":{"Line":3}},{"line":12,"address":[5862633,5862368,5862438,5862550],"length":1,"stats":{"Line":3}},{"line":13,"address":[5819009],"length":1,"stats":{"Line":3}},{"line":16,"address":[5863321,5863781,5863517,5863352,5863426],"length":1,"stats":{"Line":1}},{"line":17,"address":[5819025],"length":1,"stats":{"Line":3}},{"line":20,"address":[5819041],"length":1,"stats":{"Line":3}},{"line":21,"address":[5864758,5864702],"length":1,"stats":{"Line":2}},{"line":22,"address":[5830987],"length":1,"stats":{"Line":2}},{"line":23,"address":[5819225,5819187,5819202,5819167],"length":1,"stats":{"Line":3}},{"line":25,"address":[5831782,5831884],"length":1,"stats":{"Line":9}},{"line":26,"address":[5832205,5831760,5832211],"length":1,"stats":{"Line":3}},{"line":27,"address":[5868396,5868163,5868236,5868485],"length":1,"stats":{"Line":3}},{"line":28,"address":[5819815],"length":1,"stats":{"Line":3}},{"line":31,"address":[5869302,5869393,5869197,5869666,5869228],"length":1,"stats":{"Line":1}},{"line":32,"address":[5819836],"length":1,"stats":{"Line":3}},{"line":35,"address":[5870059,5870404,5870150,5870028],"length":1,"stats":{"Line":1}},{"line":36,"address":[5819857],"length":1,"stats":{"Line":3}},{"line":39,"address":[5819878],"length":1,"stats":{"Line":1}},{"line":40,"address":[5871110,5871182],"length":1,"stats":{"Line":2}},{"line":43,"address":[5819899],"length":1,"stats":{"Line":1}},{"line":44,"address":[5819920],"length":1,"stats":{"Line":2}},{"line":45,"address":[5831915],"length":1,"stats":{"Line":3}},{"line":46,"address":[5831847,5832184],"length":1,"stats":{"Line":3}},{"line":48,"address":[5832246,5832348],"length":1,"stats":{"Line":8}},{"line":49,"address":[5832675,5832669,5832224],"length":1,"stats":{"Line":3}},{"line":50,"address":[5872758,5872468,5872524,5872669],"length":1,"stats":{"Line":3}},{"line":51,"address":[5820548],"length":1,"stats":{"Line":3}},{"line":54,"address":[5873470],"length":1,"stats":{"Line":1}},{"line":55,"address":[5820569],"length":1,"stats":{"Line":3}},{"line":58,"address":[5820590],"length":1,"stats":{"Line":3}},{"line":59,"address":[5874586,5874650],"length":1,"stats":{"Line":2}},{"line":60,"address":[5874694],"length":1,"stats":{"Line":1}},{"line":63,"address":[5820611],"length":1,"stats":{"Line":1}},{"line":64,"address":[5820632],"length":1,"stats":{"Line":1}},{"line":65,"address":[5832379],"length":1,"stats":{"Line":2}},{"line":66,"address":[5876453,5873572,5874016,5873073,5876313,5876387,5874606],"length":1,"stats":{"Line":3}},{"line":68,"address":[5833276,5833174],"length":1,"stats":{"Line":11}},{"line":69,"address":[5833597,5833603,5833152],"length":1,"stats":{"Line":3}},{"line":70,"address":[5884817,5885064,5885168,5884726],"length":1,"stats":{"Line":3}},{"line":71,"address":[5814647],"length":1,"stats":{"Line":3}},{"line":74,"address":[5886168,5885972,5886003,5886077,5886472],"length":1,"stats":{"Line":1}},{"line":75,"address":[5814668],"length":1,"stats":{"Line":3}},{"line":77,"address":[5887244,5886980,5886852,5886883],"length":1,"stats":{"Line":1}},{"line":78,"address":[5887284,5884880,5887088,5887177],"length":1,"stats":{"Line":3}},{"line":81,"address":[5887881],"length":1,"stats":{"Line":1}},{"line":82,"address":[5887572],"length":1,"stats":{"Line":1}},{"line":83,"address":[5887599],"length":1,"stats":{"Line":1}},{"line":84,"address":[5887611],"length":1,"stats":{"Line":1}},{"line":85,"address":[5887642],"length":1,"stats":{"Line":1}},{"line":86,"address":[5887773,5887725],"length":1,"stats":{"Line":2}},{"line":87,"address":[5887813],"length":1,"stats":{"Line":1}},{"line":88,"address":[5887832,5887673,5887732],"length":1,"stats":{"Line":0}},{"line":89,"address":[5814710],"length":1,"stats":{"Line":2}},{"line":92,"address":[5888497,5888555,5888629,5888922],"length":1,"stats":{"Line":1}},{"line":93,"address":[5884922,5888752,5888855,5888962],"length":1,"stats":{"Line":2}},{"line":96,"address":[5889258],"length":1,"stats":{"Line":1}},{"line":97,"address":[5814752],"length":1,"stats":{"Line":3}},{"line":100,"address":[5814773],"length":1,"stats":{"Line":3}},{"line":101,"address":[5890306,5890376],"length":1,"stats":{"Line":2}},{"line":102,"address":[5890420],"length":1,"stats":{"Line":1}},{"line":105,"address":[5814794],"length":1,"stats":{"Line":2}},{"line":106,"address":[5833307],"length":1,"stats":{"Line":3}},{"line":107,"address":[5815060,5815149,5815258,5815124,5815280,5815194,5815082,5814934,5815216,5815271,5815298,5814959],"length":1,"stats":{"Line":3}},{"line":109,"address":[5830492,5830390],"length":1,"stats":{"Line":9}},{"line":110,"address":[5858624,5858642],"length":1,"stats":{"Line":3}},{"line":111,"address":[5858752,5858825,5859035,5858952],"length":1,"stats":{"Line":3}},{"line":112,"address":[5818222],"length":1,"stats":{"Line":3}},{"line":115,"address":[5860464],"length":1,"stats":{"Line":1}},{"line":116,"address":[5859789,5859723],"length":1,"stats":{"Line":2}},{"line":117,"address":[5859888,5860180,5859953,5860658,5860242,5859805,5859850],"length":1,"stats":{"Line":2}},{"line":118,"address":[5859812,5860508,5860722,5859754],"length":1,"stats":{"Line":1}},{"line":119,"address":[5860603,5860749,5858855,5860516],"length":1,"stats":{"Line":3}},{"line":122,"address":[5818255],"length":1,"stats":{"Line":2}},{"line":123,"address":[5861409,5861475],"length":1,"stats":{"Line":2}},{"line":126,"address":[5861581,5861686,5858885],"length":1,"stats":{"Line":2}},{"line":127,"address":[5830523],"length":1,"stats":{"Line":3}},{"line":128,"address":[5830792,5830455],"length":1,"stats":{"Line":3}},{"line":130,"address":[5865367,5865200,5865512,5865956,5868003,5865240,5865352,5865382,5865402,5865315],"length":1,"stats":{"Line":7}},{"line":131,"address":[5868032,5868050],"length":1,"stats":{"Line":3}},{"line":132,"address":[5865280,5865543,5865457,5865342],"length":1,"stats":{"Line":3}},{"line":133,"address":[5865967,5865357,5865825,5865901],"length":1,"stats":{"Line":3}},{"line":136,"address":[5866682,5866231,5866281],"length":1,"stats":{"Line":3}},{"line":137,"address":[5867285],"length":1,"stats":{"Line":1}},{"line":138,"address":[5866733,5866896],"length":1,"stats":{"Line":2}},{"line":139,"address":[5867013,5867096],"length":1,"stats":{"Line":2}},{"line":140,"address":[5867208],"length":1,"stats":{"Line":1}},{"line":141,"address":[5867239,5866861,5867345,5867445,5867044],"length":1,"stats":{"Line":1}},{"line":142,"address":[5866336,5866581,5866312,5865372,5867361],"length":1,"stats":{"Line":4}},{"line":143,"address":[5819532,5819497],"length":1,"stats":{"Line":1}},{"line":146,"address":[5819394],"length":1,"stats":{"Line":1}},{"line":147,"address":[5831448],"length":1,"stats":{"Line":3}},{"line":148,"address":[5831717,5831380],"length":1,"stats":{"Line":3}},{"line":150,"address":[5832710,5832812],"length":1,"stats":{"Line":14}},{"line":151,"address":[5876482,5876464],"length":1,"stats":{"Line":3}},{"line":152,"address":[5876706,5877016,5876622,5877120],"length":1,"stats":{"Line":3}},{"line":153,"address":[5877517,5877601,5876727,5877438],"length":1,"stats":{"Line":3}},{"line":156,"address":[5877923,5877997,5877892,5878379,5878088],"length":1,"stats":{"Line":1}},{"line":157,"address":[5821852],"length":1,"stats":{"Line":3}},{"line":159,"address":[5878762,5879156,5878887,5878793],"length":1,"stats":{"Line":1}},{"line":160,"address":[5876769,5879089,5878999,5879196],"length":1,"stats":{"Line":3}},{"line":162,"address":[5879796],"length":1,"stats":{"Line":1}},{"line":163,"address":[5879484],"length":1,"stats":{"Line":1}},{"line":164,"address":[5879511],"length":1,"stats":{"Line":1}},{"line":165,"address":[5879526],"length":1,"stats":{"Line":1}},{"line":166,"address":[5879557],"length":1,"stats":{"Line":1}},{"line":167,"address":[5879640,5879688],"length":1,"stats":{"Line":2}},{"line":168,"address":[5879728],"length":1,"stats":{"Line":1}},{"line":169,"address":[5879588,5879647,5879747],"length":1,"stats":{"Line":0}},{"line":170,"address":[5876790,5880115,5880040,5879954],"length":1,"stats":{"Line":2}},{"line":172,"address":[5880411,5880546,5880472,5880832],"length":1,"stats":{"Line":1}},{"line":173,"address":[5821915],"length":1,"stats":{"Line":2}},{"line":175,"address":[5881168],"length":1,"stats":{"Line":1}},{"line":176,"address":[5821936],"length":1,"stats":{"Line":3}},{"line":179,"address":[5881723],"length":1,"stats":{"Line":1}},{"line":180,"address":[5821957],"length":1,"stats":{"Line":3}},{"line":183,"address":[5882270,5882416,5876874],"length":1,"stats":{"Line":2}},{"line":184,"address":[5821999],"length":1,"stats":{"Line":2}},{"line":185,"address":[5883140],"length":1,"stats":{"Line":1}},{"line":188,"address":[5822020],"length":1,"stats":{"Line":2}},{"line":189,"address":[5822041],"length":1,"stats":{"Line":2}},{"line":190,"address":[5832843],"length":1,"stats":{"Line":2}},{"line":191,"address":[5876501,5879981,5879033,5877465,5878250,5881301,5880706,5881840,5884398],"length":1,"stats":{"Line":3}},{"line":193,"address":[5833740,5833638],"length":1,"stats":{"Line":12}},{"line":194,"address":[5833616,5834061,5834067],"length":1,"stats":{"Line":3}},{"line":195,"address":[5891726,5892137,5892036,5891810],"length":1,"stats":{"Line":3}},{"line":196,"address":[5891831,5892606,5892525,5892446],"length":1,"stats":{"Line":3}},{"line":199,"address":[5893090,5892999,5892894,5892925,5893378],"length":1,"stats":{"Line":1}},{"line":200,"address":[5815564],"length":1,"stats":{"Line":3}},{"line":202,"address":[5893883,5893758,5893789,5894152],"length":1,"stats":{"Line":1}},{"line":203,"address":[5815585],"length":1,"stats":{"Line":3}},{"line":205,"address":[5894792],"length":1,"stats":{"Line":1}},{"line":206,"address":[5894480],"length":1,"stats":{"Line":1}},{"line":207,"address":[5894507],"length":1,"stats":{"Line":1}},{"line":208,"address":[5894522],"length":1,"stats":{"Line":1}},{"line":209,"address":[5894553],"length":1,"stats":{"Line":1}},{"line":210,"address":[5894684,5894636],"length":1,"stats":{"Line":2}},{"line":211,"address":[5894724],"length":1,"stats":{"Line":1}},{"line":212,"address":[5894643,5894743,5894584],"length":1,"stats":{"Line":0}},{"line":213,"address":[5815606],"length":1,"stats":{"Line":3}},{"line":215,"address":[5895542,5895828,5895468,5895407],"length":1,"stats":{"Line":1}},{"line":216,"address":[5815627],"length":1,"stats":{"Line":3}},{"line":219,"address":[5833771,5833863],"length":1,"stats":{"Line":5}},{"line":220,"address":[5815648],"length":1,"stats":{"Line":4}},{"line":221,"address":[5815669],"length":1,"stats":{"Line":3}},{"line":222,"address":[5896606],"length":1,"stats":{"Line":1}},{"line":223,"address":[5833958,5833814],"length":1,"stats":{"Line":0}},{"line":224,"address":[5815993,5816015,5815881,5815859,5815948,5815817,5815923,5815795],"length":1,"stats":{"Line":3}}],"covered":145,"coverable":149},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-events","src","bus.rs"],"content":"//! Event bus implementation\n\nuse super::event::{EventEnvelope, EventType};\nuse async_trait::async_trait;\nuse futures::Stream;\nuse gitforce_common::Result;\nuse std::pin::Pin;\nuse tokio::sync::broadcast;\n\n/// Event filter for subscriptions\n#[derive(Debug, Clone, Default)]\npub struct EventFilter {\n /// Filter by event types\n pub event_types: Option>,\n /// Filter by repository ID\n pub repo_id: Option,\n}\n\nimpl EventFilter {\n /// Create a filter that matches all events\n pub fn all() -> Self {\n Self::default()\n }\n\n /// Create a filter for specific event types\n pub fn for_types(event_types: Vec) -> Self {\n Self {\n event_types: Some(event_types),\n repo_id: None,\n }\n }\n\n /// Create a filter for a specific repository\n pub fn for_repo(repo_id: gitforce_common::RepoId) -> Self {\n Self {\n event_types: None,\n repo_id: Some(repo_id),\n }\n }\n\n /// Check if an event matches this filter\n pub fn matches(&self, event: &EventEnvelope) -> bool {\n // Check event type\n if let Some(ref types) = self.event_types {\n if !types.contains(&event.event_type) {\n return false;\n }\n }\n\n // Check repo_id\n if let Some(ref repo_id) = self.repo_id {\n if event.repo_id != Some(*repo_id) {\n return false;\n }\n }\n\n true\n }\n}\n\n/// Event bus trait\n#[async_trait]\npub trait EventBus: Send + Sync {\n /// Publish an event to the bus\n async fn publish(&self, event: EventEnvelope) -> Result<()>;\n\n /// Subscribe to events matching a filter\n async fn subscribe(&self, filter: EventFilter) -> Result>;\n}\n\n/// Event stream trait\n#[async_trait]\npub trait EventStream: Send + Sync + Stream + Unpin {\n /// Get the filter for this stream\n fn filter(&self) -> &EventFilter;\n}\n\n/// In-memory event bus implementation using broadcast channels\n#[derive(Debug)]\npub struct InMemoryEventBus {\n sender: broadcast::Sender,\n}\n\nimpl InMemoryEventBus {\n /// Create a new in-memory event bus\n pub fn new() -> Self {\n let (sender, _) = broadcast::channel(1024);\n Self { sender }\n }\n\n /// Get the number of active subscribers\n pub async fn subscriber_count(&self) -> usize {\n 0\n }\n}\n\nimpl Default for InMemoryEventBus {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl EventBus for InMemoryEventBus {\n async fn publish(&self, event: EventEnvelope) -> Result<()> {\n self.sender\n .send(event)\n .map_err(|e| gitforce_common::Error::event_system(format!(\"publish failed: {}\", e)))?;\n\n Ok(())\n }\n\n async fn subscribe(&self, filter: EventFilter) -> Result> {\n let rx = self.sender.subscribe();\n\n Ok(Box::new(InMemoryEventStream {\n rx,\n filter,\n }))\n }\n}\n\n/// In-memory event stream\nstruct InMemoryEventStream {\n rx: broadcast::Receiver,\n filter: EventFilter,\n}\n\nimpl Stream for InMemoryEventStream {\n type Item = EventEnvelope;\n\n fn poll_next(\n mut self: Pin<&mut Self>,\n cx: &mut std::task::Context<'_>,\n ) -> std::task::Poll> {\n use std::task::Poll;\n\n loop {\n match self.rx.try_recv() {\n Ok(event) => {\n if self.filter.matches(&event) {\n return Poll::Ready(Some(event));\n }\n // Continue looking for matching event\n }\n Err(broadcast::error::TryRecvError::Empty) => {\n // No message available, register waker and return pending\n let waker = cx.waker().clone();\n std::thread::spawn(move || {\n std::thread::sleep(std::time::Duration::from_millis(10));\n waker.wake();\n });\n return Poll::Pending;\n }\n Err(broadcast::error::TryRecvError::Closed) => return Poll::Ready(None),\n Err(broadcast::error::TryRecvError::Lagged(_)) => {\n // Subscriber fell behind, continue with next event\n }\n }\n }\n }\n}\n\n#[async_trait]\nimpl EventStream for InMemoryEventStream {\n fn filter(&self) -> &EventFilter {\n &self.filter\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::event::{PushReceivedPayload, EventType, EventPayload};\n\n #[tokio::test]\n async fn test_publish_subscribe() {\n let bus = InMemoryEventBus::new();\n\n // Subscribe to push events\n let stream = bus\n .subscribe(EventFilter::for_types(vec![EventType::PushReceived]))\n .await\n .unwrap();\n\n // Pin the stream - tokio::pin! handles the boxing correctly\n tokio::pin!(stream);\n\n // Publish an event\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n\n bus.publish(event.clone()).await.unwrap();\n\n // Receive should get the event\n let received = tokio::time::timeout(std::time::Duration::from_secs(1), futures::StreamExt::next(&mut stream))\n .await\n .unwrap();\n\n assert!(received.is_some());\n }\n\n #[test]\n fn test_event_filter_all() {\n let filter = EventFilter::all();\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n assert!(filter.matches(&event));\n }\n\n #[test]\n fn test_event_filter_for_types() {\n let filter = EventFilter::for_types(vec![EventType::PushReceived]);\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n assert!(filter.matches(&event));\n }\n\n #[test]\n fn test_event_filter_for_types_no_match() {\n let filter = EventFilter::for_types(vec![EventType::PushReceived]);\n let event = EventEnvelope::new(\n EventType::JobQueued,\n EventPayload::JobQueued(crate::event::JobQueuedPayload {\n job_id: gitforce_common::JobId::new(),\n pipeline_run_id: gitforce_common::PipelineRunId::new(),\n name: \"test-job\".to_string(),\n }),\n None,\n None,\n );\n assert!(!filter.matches(&event));\n }\n\n #[test]\n fn test_event_filter_for_repo() {\n let repo_id = gitforce_common::RepoId::new();\n let filter = EventFilter::for_repo(repo_id);\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id,\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n Some(repo_id), // Pass repo_id to EventEnvelope::new\n None,\n );\n assert!(filter.matches(&event));\n }\n\n #[test]\n fn test_event_filter_for_repo_no_match() {\n let filter = EventFilter::for_repo(gitforce_common::RepoId::new());\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n assert!(!filter.matches(&event));\n }\n\n #[tokio::test]\n async fn test_in_memory_event_bus_new() {\n let bus = InMemoryEventBus::new();\n // subscriber_count is async but always returns 0 currently\n assert_eq!(bus.subscriber_count().await, 0);\n }\n\n #[tokio::test]\n async fn test_in_memory_event_bus_subscribe_all() {\n let bus = InMemoryEventBus::new();\n let stream = bus.subscribe(EventFilter::all()).await.unwrap();\n let filter = stream.filter();\n assert!(filter.event_types.is_none());\n assert!(filter.repo_id.is_none());\n }\n\n #[test]\n fn test_event_filter_debug() {\n let filter = EventFilter::all();\n let debug_str = format!(\"{:?}\", filter);\n assert!(debug_str.contains(\"EventFilter\"));\n }\n\n #[test]\n fn test_in_memory_event_bus_debug() {\n let bus = InMemoryEventBus::new();\n let debug_str = format!(\"{:?}\", bus);\n assert!(debug_str.contains(\"InMemoryEventBus\"));\n }\n\n #[test]\n fn test_event_filter_clone() {\n let filter = EventFilter::for_types(vec![EventType::PushReceived]);\n let cloned = filter.clone();\n assert!(cloned.event_types.is_some());\n }\n\n #[tokio::test]\n async fn test_publish_multiple_events() {\n use crate::event::{EventEnvelope, EventPayload, EventType, PushReceivedPayload};\n let bus = InMemoryEventBus::new();\n\n // Subscribe first so broadcast has a receiver\n let _stream = bus.subscribe(EventFilter::all()).await.unwrap();\n\n // Publish multiple events\n for i in 0..5 {\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: format!(\"refs/heads/branch{}\", i),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n bus.publish(event).await.unwrap();\n }\n // If we get here without panic, the test passes\n }\n\n #[test]\n fn test_event_filter_default() {\n let filter = EventFilter::default();\n assert!(filter.event_types.is_none());\n assert!(filter.repo_id.is_none());\n }\n\n #[test]\n fn test_event_filter_with_both_conditions() {\n let repo_id = gitforce_common::RepoId::new();\n let filter = EventFilter {\n event_types: Some(vec![EventType::PushReceived]),\n repo_id: Some(repo_id),\n };\n\n // Event matching both\n let event_matching = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id,\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n Some(repo_id),\n None,\n );\n assert!(filter.matches(&event_matching));\n\n // Event not matching type\n let event_wrong_type = EventEnvelope::new(\n EventType::JobQueued,\n EventPayload::JobQueued(crate::event::JobQueuedPayload {\n job_id: gitforce_common::JobId::new(),\n pipeline_run_id: gitforce_common::PipelineRunId::new(),\n name: \"test\".to_string(),\n }),\n Some(repo_id),\n None,\n );\n assert!(!filter.matches(&event_wrong_type));\n }\n\n #[test]\n fn test_event_filter_matches_with_no_type_filter() {\n let repo_id = gitforce_common::RepoId::new();\n let filter = EventFilter {\n event_types: None,\n repo_id: Some(repo_id),\n };\n\n // Any event type should match if repo matches\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id,\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n Some(repo_id),\n None,\n );\n assert!(filter.matches(&event));\n }\n\n #[test]\n fn test_event_filter_matches_with_no_repo_filter() {\n let filter = EventFilter {\n event_types: Some(vec![EventType::PushReceived]),\n repo_id: None,\n };\n\n // Any repo should match if type matches\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc\".to_string(),\n new_hash: \"def\".to_string(),\n pusher_id: None,\n }),\n Some(gitforce_common::RepoId::new()),\n None,\n );\n assert!(filter.matches(&event));\n }\n\n #[tokio::test]\n async fn test_event_bus_publish_error_channel_closed() {\n use std::sync::Arc;\n\n // Create a bus and subscribe to get a receiver\n let bus = Arc::new(InMemoryEventBus::new());\n let _stream = bus.subscribe(EventFilter::all()).await.unwrap();\n\n // Drop the sender by dropping the bus\n drop(bus);\n\n // Trying to publish to dropped bus would error\n // This test just verifies the bus can be created and dropped\n // without panic. The actual error behavior is tested elsewhere.\n }\n}\n","traces":[{"line":21,"address":[9392960],"length":1,"stats":{"Line":2}},{"line":22,"address":[2825336],"length":1,"stats":{"Line":5}},{"line":26,"address":[2825696],"length":1,"stats":{"Line":3}},{"line":28,"address":[2825699],"length":1,"stats":{"Line":2}},{"line":34,"address":[9393248],"length":1,"stats":{"Line":2}},{"line":37,"address":[9393266],"length":1,"stats":{"Line":2}},{"line":42,"address":[9392992],"length":1,"stats":{"Line":7}},{"line":44,"address":[2825383],"length":1,"stats":{"Line":8}},{"line":45,"address":[9393056],"length":1,"stats":{"Line":4}},{"line":46,"address":[9393114],"length":1,"stats":{"Line":1}},{"line":51,"address":[9393096,9393139],"length":1,"stats":{"Line":8}},{"line":52,"address":[2825521],"length":1,"stats":{"Line":4}},{"line":53,"address":[9393227],"length":1,"stats":{"Line":1}},{"line":57,"address":[9393220],"length":1,"stats":{"Line":3}},{"line":86,"address":[2825900,2825894,2825808],"length":1,"stats":{"Line":5}},{"line":87,"address":[9393460],"length":1,"stats":{"Line":5}},{"line":92,"address":[20350552,20350544],"length":1,"stats":{"Line":4}},{"line":98,"address":[20351280],"length":1,"stats":{"Line":0}},{"line":99,"address":[2826497],"length":1,"stats":{"Line":0}},{"line":105,"address":[2619895,2619697,2620282,2619914,2620294,2620229,2619552,2619590,2619775],"length":1,"stats":{"Line":10}},{"line":106,"address":[2619994,2619868,2620272,2620086],"length":1,"stats":{"Line":4}},{"line":107,"address":[20352619],"length":1,"stats":{"Line":2}},{"line":108,"address":[2619971,2620038,2620384,2620406],"length":1,"stats":{"Line":2}},{"line":110,"address":[2620180],"length":1,"stats":{"Line":2}},{"line":113,"address":[9396565,9396657,9397024,9396288,9396326,9396438,9396546],"length":1,"stats":{"Line":14}},{"line":114,"address":[2621104,2620944],"length":1,"stats":{"Line":7}},{"line":116,"address":[2621176],"length":1,"stats":{"Line":4}},{"line":118,"address":[9396808],"length":1,"stats":{"Line":4}},{"line":132,"address":[2826656,2827016,2827022],"length":1,"stats":{"Line":1}},{"line":139,"address":[20351507,20351866],"length":1,"stats":{"Line":1}},{"line":140,"address":[2826817],"length":1,"stats":{"Line":1}},{"line":141,"address":[20351707,20351637],"length":1,"stats":{"Line":2}},{"line":142,"address":[9394655],"length":1,"stats":{"Line":1}},{"line":148,"address":[2827042],"length":1,"stats":{"Line":0}},{"line":149,"address":[9397136,9397253,9397279],"length":1,"stats":{"Line":0}},{"line":150,"address":[9397215,9397150],"length":1,"stats":{"Line":0}},{"line":151,"address":[2621530],"length":1,"stats":{"Line":0}},{"line":153,"address":[2827106],"length":1,"stats":{"Line":0}},{"line":155,"address":[2827126],"length":1,"stats":{"Line":0}},{"line":166,"address":[2827168],"length":1,"stats":{"Line":1}}],"covered":32,"coverable":40},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-events","src","event.rs"],"content":"//! Event envelope and core event types\n\nuse chrono::{DateTime, Utc};\nuse gitforce_common::{JobId, PipelineId, PipelineRunId, RepoId, RunnerId, UserId};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Event envelope - all events are wrapped in this\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct EventEnvelope {\n /// Unique event identifier\n pub event_id: Uuid,\n /// Event type discriminator\n pub event_type: EventType,\n /// Event schema version\n pub event_version: u8,\n /// Event timestamp (Unix milliseconds)\n pub timestamp: i64,\n /// Associated repository (if applicable)\n pub repo_id: Option,\n /// Actor who triggered the event (if applicable)\n pub actor_id: Option,\n /// Correlation ID for tracing related events\n pub correlation_id: Option,\n /// Event-specific payload\n pub payload: EventPayload,\n}\n\nimpl EventEnvelope {\n /// Create a new event envelope\n pub fn new(\n event_type: EventType,\n payload: EventPayload,\n repo_id: Option,\n actor_id: Option,\n ) -> Self {\n Self {\n event_id: Uuid::new_v4(),\n event_type,\n event_version: 1,\n timestamp: Utc::now().timestamp_millis(),\n repo_id,\n actor_id,\n correlation_id: None,\n payload,\n }\n }\n\n /// Create a new event with a correlation ID\n pub fn with_correlation(\n mut self,\n correlation_id: Uuid,\n ) -> Self {\n self.correlation_id = Some(correlation_id);\n self\n }\n\n /// Get the timestamp as a DateTime\n pub fn timestamp_datetime(&self) -> DateTime {\n DateTime::from_timestamp_millis(self.timestamp).unwrap_or_default()\n }\n}\n\n/// Event types\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum EventType {\n // Repository events\n RepoCreated,\n RepoDeleted,\n\n // Git events\n PushReceived,\n RefUpdated,\n\n // Pipeline events\n PipelineTriggered,\n PipelineStarted,\n PipelineFinished,\n\n // Job events\n JobQueued,\n JobStarted,\n JobFinished,\n\n // Artifact events\n ArtifactCreated,\n\n // Runner events\n RunnerRegistered,\n RunnerHeartbeat,\n RunnerOffline,\n\n // Mirror events\n MirrorSyncRequested,\n MirrorSyncCompleted,\n}\n\nimpl EventType {\n /// Get the event type name as a string\n pub fn as_str(&self) -> &'static str {\n match self {\n EventType::RepoCreated => \"repo.created\",\n EventType::RepoDeleted => \"repo.deleted\",\n EventType::PushReceived => \"push.received\",\n EventType::RefUpdated => \"ref.updated\",\n EventType::PipelineTriggered => \"pipeline.triggered\",\n EventType::PipelineStarted => \"pipeline.started\",\n EventType::PipelineFinished => \"pipeline.finished\",\n EventType::JobQueued => \"job.queued\",\n EventType::JobStarted => \"job.started\",\n EventType::JobFinished => \"job.finished\",\n EventType::ArtifactCreated => \"artifact.created\",\n EventType::RunnerRegistered => \"runner.registered\",\n EventType::RunnerHeartbeat => \"runner.heartbeat\",\n EventType::RunnerOffline => \"runner.offline\",\n EventType::MirrorSyncRequested => \"mirror.sync_requested\",\n EventType::MirrorSyncCompleted => \"mirror.sync_completed\",\n }\n }\n}\n\nimpl std::fmt::Display for EventType {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.as_str())\n }\n}\n\n/// Event payloads\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum EventPayload {\n // Repository payloads\n RepoCreated(RepoCreatedPayload),\n RepoDeleted(RepoDeletedPayload),\n\n // Git payloads\n PushReceived(PushReceivedPayload),\n RefUpdated(RefUpdatedPayload),\n\n // Pipeline payloads\n PipelineTriggered(PipelineTriggeredPayload),\n PipelineStarted(PipelineStartedPayload),\n PipelineFinished(PipelineFinishedPayload),\n\n // Job payloads\n JobQueued(JobQueuedPayload),\n JobStarted(JobStartedPayload),\n JobFinished(JobFinishedPayload),\n\n // Artifact payloads\n ArtifactCreated(ArtifactCreatedPayload),\n\n // Runner payloads\n RunnerRegistered(RunnerRegisteredPayload),\n RunnerHeartbeat(RunnerHeartbeatPayload),\n RunnerOffline(RunnerOfflinePayload),\n\n // Mirror payloads\n MirrorSyncRequested(MirrorSyncRequestedPayload),\n MirrorSyncCompleted(MirrorSyncCompletedPayload),\n}\n\n/// Repository created payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RepoCreatedPayload {\n pub repo_id: RepoId,\n pub name: String,\n pub owner_id: UserId,\n pub visibility: String,\n}\n\n/// Repository deleted payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RepoDeletedPayload {\n pub repo_id: RepoId,\n pub name: String,\n}\n\n/// Push received payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PushReceivedPayload {\n pub repo_id: RepoId,\n pub ref_name: String,\n pub old_hash: String,\n pub new_hash: String,\n pub pusher_id: Option,\n}\n\n/// Ref updated payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RefUpdatedPayload {\n pub repo_id: RepoId,\n pub ref_name: String,\n pub old_hash: String,\n pub new_hash: String,\n}\n\n/// Pipeline triggered payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineTriggeredPayload {\n pub pipeline_id: PipelineId,\n pub pipeline_run_id: PipelineRunId,\n pub repo_id: RepoId,\n pub commit_hash: String,\n pub trigger_source: String,\n}\n\n/// Pipeline started payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineStartedPayload {\n pub pipeline_run_id: PipelineRunId,\n pub started_at: i64,\n}\n\n/// Pipeline finished payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PipelineFinishedPayload {\n pub pipeline_run_id: PipelineRunId,\n pub status: String,\n pub duration_ms: u64,\n}\n\n/// Job queued payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobQueuedPayload {\n pub job_id: JobId,\n pub pipeline_run_id: PipelineRunId,\n pub name: String,\n}\n\n/// Job started payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobStartedPayload {\n pub job_id: JobId,\n pub runner_id: RunnerId,\n pub started_at: i64,\n}\n\n/// Job finished payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobFinishedPayload {\n pub job_id: JobId,\n pub status: String,\n pub exit_code: i32,\n pub duration_ms: u64,\n}\n\n/// Artifact created payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ArtifactCreatedPayload {\n pub artifact_id: Uuid,\n pub job_id: JobId,\n pub path: String,\n pub checksum: String,\n pub size_bytes: u64,\n}\n\n/// Runner registered payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RunnerRegisteredPayload {\n pub runner_id: RunnerId,\n pub name: String,\n pub runner_type: String,\n pub capacity: i32,\n}\n\n/// Runner heartbeat payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RunnerHeartbeatPayload {\n pub runner_id: RunnerId,\n pub capacity_used: u32,\n pub active_jobs: u32,\n}\n\n/// Runner offline payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RunnerOfflinePayload {\n pub runner_id: RunnerId,\n pub reason: String,\n}\n\n/// Mirror sync requested payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MirrorSyncRequestedPayload {\n pub repo_id: RepoId,\n pub github_repo: String,\n pub branch: Option,\n}\n\n/// Mirror sync completed payload\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MirrorSyncCompletedPayload {\n pub repo_id: RepoId,\n pub github_repo: String,\n pub commit_hash: String,\n pub success: bool,\n pub error: Option,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_event_type_str() {\n assert_eq!(EventType::PushReceived.as_str(), \"push.received\");\n assert_eq!(EventType::PipelineTriggered.as_str(), \"pipeline.triggered\");\n assert_eq!(EventType::JobQueued.as_str(), \"job.queued\");\n assert_eq!(EventType::JobStarted.as_str(), \"job.started\");\n assert_eq!(EventType::JobFinished.as_str(), \"job.finished\");\n }\n\n #[test]\n fn test_event_envelope_creation() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n );\n\n assert_eq!(event.event_version, 1);\n assert!(event.timestamp > 0);\n }\n\n #[test]\n fn test_job_queued_payload() {\n let payload = JobQueuedPayload {\n job_id: JobId::new(),\n pipeline_run_id: PipelineRunId::new(),\n name: \"build\".to_string(),\n };\n assert_eq!(payload.name, \"build\");\n }\n\n #[test]\n fn test_job_started_payload() {\n let payload = JobStartedPayload {\n job_id: JobId::new(),\n runner_id: RunnerId::new(),\n started_at: 1234567890,\n };\n assert_eq!(payload.started_at, 1234567890);\n }\n\n #[test]\n fn test_job_finished_payload() {\n let payload = JobFinishedPayload {\n job_id: JobId::new(),\n status: \"succeeded\".to_string(),\n exit_code: 0,\n duration_ms: 5000,\n };\n assert_eq!(payload.status, \"succeeded\");\n assert_eq!(payload.exit_code, 0);\n }\n\n #[test]\n fn test_artifact_created_payload() {\n let payload = ArtifactCreatedPayload {\n artifact_id: Uuid::new_v4(),\n job_id: JobId::new(),\n path: \"/artifacts/test.zip\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 1024,\n };\n assert_eq!(payload.path, \"/artifacts/test.zip\");\n assert_eq!(payload.size_bytes, 1024);\n }\n\n #[test]\n fn test_runner_registered_payload() {\n let payload = RunnerRegisteredPayload {\n runner_id: RunnerId::new(),\n name: \"runner-1\".to_string(),\n runner_type: \"docker\".to_string(),\n capacity: 4,\n };\n assert_eq!(payload.name, \"runner-1\");\n assert_eq!(payload.capacity, 4);\n }\n\n #[test]\n fn test_runner_heartbeat_payload() {\n let payload = RunnerHeartbeatPayload {\n runner_id: RunnerId::new(),\n capacity_used: 2,\n active_jobs: 1,\n };\n assert_eq!(payload.capacity_used, 2);\n assert_eq!(payload.active_jobs, 1);\n }\n\n #[test]\n fn test_mirror_sync_completed_payload() {\n let payload = MirrorSyncCompletedPayload {\n repo_id: RepoId::new(),\n github_repo: \"owner/repo\".to_string(),\n commit_hash: \"abc123\".to_string(),\n success: true,\n error: None,\n };\n assert!(payload.success);\n assert!(payload.error.is_none());\n }\n\n #[test]\n fn test_pipeline_triggered_payload() {\n let payload = PipelineTriggeredPayload {\n pipeline_id: PipelineId::new(),\n pipeline_run_id: PipelineRunId::new(),\n repo_id: RepoId::new(),\n commit_hash: \"abc123\".to_string(),\n trigger_source: \"push\".to_string(),\n };\n assert_eq!(payload.trigger_source, \"push\");\n }\n\n #[test]\n fn test_pipeline_started_payload() {\n let payload = PipelineStartedPayload {\n pipeline_run_id: PipelineRunId::new(),\n started_at: 1234567890,\n };\n assert_eq!(payload.started_at, 1234567890);\n }\n\n #[test]\n fn test_pipeline_finished_payload() {\n let payload = PipelineFinishedPayload {\n pipeline_run_id: PipelineRunId::new(),\n status: \"succeeded\".to_string(),\n duration_ms: 60000,\n };\n assert_eq!(payload.status, \"succeeded\");\n assert_eq!(payload.duration_ms, 60000);\n }\n\n #[test]\n fn test_push_received_payload() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: Some(UserId::new()),\n };\n assert!(payload.pusher_id.is_some());\n assert_eq!(payload.old_hash, \"abc123\");\n }\n\n #[test]\n fn test_ref_updated_payload() {\n let payload = RefUpdatedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/tags/v1.0\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n };\n assert_eq!(payload.ref_name, \"refs/tags/v1.0\");\n }\n\n #[test]\n fn test_repo_created_payload() {\n let payload = RepoCreatedPayload {\n repo_id: RepoId::new(),\n name: \"test-repo\".to_string(),\n owner_id: UserId::new(),\n visibility: \"public\".to_string(),\n };\n assert_eq!(payload.visibility, \"public\");\n }\n\n #[test]\n fn test_repo_deleted_payload() {\n let payload = RepoDeletedPayload {\n repo_id: RepoId::new(),\n name: \"test-repo\".to_string(),\n };\n assert_eq!(payload.name, \"test-repo\");\n }\n\n #[test]\n fn test_runner_offline_payload() {\n let payload = RunnerOfflinePayload {\n runner_id: RunnerId::new(),\n reason: \"heartbeat timeout\".to_string(),\n };\n assert_eq!(payload.reason, \"heartbeat timeout\");\n }\n\n #[test]\n fn test_mirror_sync_requested_payload() {\n let payload = MirrorSyncRequestedPayload {\n repo_id: RepoId::new(),\n github_repo: \"owner/repo\".to_string(),\n branch: Some(\"main\".to_string()),\n };\n assert_eq!(payload.github_repo, \"owner/repo\");\n assert_eq!(payload.branch, Some(\"main\".to_string()));\n }\n\n #[test]\n fn test_event_envelope_with_correlation() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let correlation_id = Uuid::new_v4();\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n ).with_correlation(correlation_id);\n\n assert_eq!(event.correlation_id, Some(correlation_id));\n }\n\n #[test]\n fn test_event_envelope_timestamp_datetime() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n );\n\n let dt = event.timestamp_datetime();\n assert_eq!(dt.timestamp_millis(), event.timestamp);\n }\n\n #[test]\n fn test_event_type_display() {\n assert_eq!(format!(\"{}\", EventType::PushReceived), \"push.received\");\n assert_eq!(format!(\"{}\", EventType::PipelineTriggered), \"pipeline.triggered\");\n assert_eq!(format!(\"{}\", EventType::RunnerHeartbeat), \"runner.heartbeat\");\n }\n\n #[test]\n fn test_event_type_equality() {\n assert_eq!(EventType::PushReceived, EventType::PushReceived);\n assert_ne!(EventType::PushReceived, EventType::PipelineTriggered);\n }\n\n #[test]\n fn test_event_type_hash() {\n use std::collections::HashSet;\n let mut set = HashSet::new();\n set.insert(EventType::PushReceived);\n set.insert(EventType::PipelineTriggered);\n assert_eq!(set.len(), 2);\n set.insert(EventType::PushReceived);\n assert_eq!(set.len(), 2);\n }\n\n #[test]\n fn test_event_envelope_all_event_types() {\n let test_cases = vec![\n (EventType::RepoCreated, \"repo.created\"),\n (EventType::RepoDeleted, \"repo.deleted\"),\n (EventType::PushReceived, \"push.received\"),\n (EventType::RefUpdated, \"ref.updated\"),\n (EventType::PipelineTriggered, \"pipeline.triggered\"),\n (EventType::PipelineStarted, \"pipeline.started\"),\n (EventType::PipelineFinished, \"pipeline.finished\"),\n (EventType::JobQueued, \"job.queued\"),\n (EventType::JobStarted, \"job.started\"),\n (EventType::JobFinished, \"job.finished\"),\n (EventType::ArtifactCreated, \"artifact.created\"),\n (EventType::RunnerRegistered, \"runner.registered\"),\n (EventType::RunnerHeartbeat, \"runner.heartbeat\"),\n (EventType::RunnerOffline, \"runner.offline\"),\n (EventType::MirrorSyncRequested, \"mirror.sync_requested\"),\n (EventType::MirrorSyncCompleted, \"mirror.sync_completed\"),\n ];\n\n for (event_type, expected_str) in test_cases {\n assert_eq!(event_type.as_str(), expected_str);\n assert_eq!(format!(\"{}\", event_type), expected_str);\n }\n }\n\n #[test]\n fn test_event_envelope_debug() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n );\n\n let debug_str = format!(\"{:?}\", event);\n assert!(debug_str.contains(\"PushReceived\"));\n assert!(debug_str.contains(\"event_id\"));\n }\n\n #[test]\n fn test_event_envelope_serde_roundtrip() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n );\n\n let json = serde_json::to_string(&event).unwrap();\n assert!(json.contains(\"push_received\"));\n\n let deserialized: EventEnvelope = serde_json::from_str(&json).unwrap();\n assert_eq!(deserialized.event_type, EventType::PushReceived);\n }\n\n #[test]\n fn test_event_payload_serde() {\n let payloads = vec![\n EventPayload::RepoCreated(RepoCreatedPayload {\n repo_id: RepoId::new(),\n name: \"test-repo\".to_string(),\n owner_id: UserId::new(),\n visibility: \"public\".to_string(),\n }),\n EventPayload::RepoDeleted(RepoDeletedPayload {\n repo_id: RepoId::new(),\n name: \"test-repo\".to_string(),\n }),\n ];\n\n for payload in payloads {\n let json = serde_json::to_string(&payload).unwrap();\n assert!(!json.is_empty());\n }\n }\n\n #[test]\n fn test_event_type_serde() {\n let event_type = EventType::PushReceived;\n let json = serde_json::to_string(&event_type).unwrap();\n assert_eq!(json, \"\\\"push_received\\\"\");\n let deserialized: EventType = serde_json::from_str(&json).unwrap();\n assert_eq!(deserialized, EventType::PushReceived);\n }\n\n #[test]\n fn test_event_envelope_with_both_ids() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n Some(RepoId::new()),\n Some(UserId::new()),\n );\n\n assert!(event.repo_id.is_some());\n assert!(event.actor_id.is_some());\n }\n\n #[test]\n fn test_event_envelope_timestamp_valid() {\n let payload = PushReceivedPayload {\n repo_id: RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n };\n\n let before = chrono::Utc::now().timestamp_millis();\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(payload),\n None,\n None,\n );\n let after = chrono::Utc::now().timestamp_millis();\n\n assert!(event.timestamp >= before);\n assert!(event.timestamp <= after);\n }\n}\n","traces":[{"line":31,"address":[9898095,9897744],"length":1,"stats":{"Line":4}},{"line":38,"address":[2560798],"length":1,"stats":{"Line":4}},{"line":41,"address":[2560851],"length":1,"stats":{"Line":7}},{"line":50,"address":[2560592],"length":1,"stats":{"Line":1}},{"line":54,"address":[20854700],"length":1,"stats":{"Line":1}},{"line":55,"address":[20854756],"length":1,"stats":{"Line":1}},{"line":59,"address":[9897680],"length":1,"stats":{"Line":1}},{"line":60,"address":[2560706],"length":1,"stats":{"Line":1}},{"line":101,"address":[20855232],"length":1,"stats":{"Line":1}},{"line":102,"address":[2561125],"length":1,"stats":{"Line":1}},{"line":103,"address":[2561156],"length":1,"stats":{"Line":1}},{"line":104,"address":[9898190],"length":1,"stats":{"Line":1}},{"line":105,"address":[2561208],"length":1,"stats":{"Line":1}},{"line":106,"address":[20855346],"length":1,"stats":{"Line":1}},{"line":107,"address":[20855372],"length":1,"stats":{"Line":2}},{"line":108,"address":[9898294],"length":1,"stats":{"Line":1}},{"line":109,"address":[2561312],"length":1,"stats":{"Line":1}},{"line":110,"address":[2561338],"length":1,"stats":{"Line":1}},{"line":111,"address":[20855476],"length":1,"stats":{"Line":1}},{"line":112,"address":[2561390],"length":1,"stats":{"Line":1}},{"line":113,"address":[2561416],"length":1,"stats":{"Line":1}},{"line":114,"address":[9898447],"length":1,"stats":{"Line":1}},{"line":115,"address":[9898470],"length":1,"stats":{"Line":1}},{"line":116,"address":[9898493],"length":1,"stats":{"Line":1}},{"line":117,"address":[2561508],"length":1,"stats":{"Line":1}},{"line":118,"address":[2561531],"length":1,"stats":{"Line":1}},{"line":124,"address":[2564448],"length":1,"stats":{"Line":1}},{"line":125,"address":[2564467],"length":1,"stats":{"Line":2}}],"covered":28,"coverable":28},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-events","src","lib.rs"],"content":"//! GitForce Event System\n//!\n//! Event bus and event type definitions for GitForce.\n\npub mod bus;\npub mod event;\npub mod serializer;\npub mod types;\n\npub use bus::{EventBus, EventStream, InMemoryEventBus, EventFilter};\npub use event::EventEnvelope;\npub use serializer::EventSerializer;\npub use types::*;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-events","src","serializer.rs"],"content":"//! Event serialization\n\nuse super::event::EventEnvelope;\nuse serde::{Deserialize, Serialize};\n\n/// JSON serializer for events\npub struct EventSerializer;\n\nimpl EventSerializer {\n /// Serialize an event to JSON bytes\n pub fn serialize(event: &EventEnvelope) -> Result, serde_json::Error> {\n serde_json::to_vec(event)\n }\n\n /// Serialize an event to a JSON string\n pub fn serialize_to_string(event: &EventEnvelope) -> Result {\n serde_json::to_string(event)\n }\n\n /// Deserialize an event from JSON bytes\n pub fn deserialize(bytes: &[u8]) -> Result {\n serde_json::from_slice(bytes)\n }\n\n /// Deserialize an event from a JSON string\n pub fn deserialize_from_str(s: &str) -> Result {\n serde_json::from_str(s)\n }\n}\n\n/// JSON representation for external systems\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JsonEvent {\n pub event_id: String,\n pub event_type: String,\n pub event_version: u8,\n pub timestamp: i64,\n pub repo_id: Option,\n pub actor_id: Option,\n pub correlation_id: Option,\n pub payload: serde_json::Value,\n}\n\nimpl JsonEvent {\n /// Convert from EventEnvelope\n pub fn from_envelope(event: &EventEnvelope) -> Self {\n Self {\n event_id: event.event_id.to_string(),\n event_type: event.event_type.to_string(),\n event_version: event.event_version,\n timestamp: event.timestamp,\n repo_id: event.repo_id.map(|id| id.to_string()),\n actor_id: event.actor_id.map(|id| id.to_string()),\n correlation_id: event.correlation_id.map(|id| id.to_string()),\n payload: serde_json::to_value(&event.payload).unwrap_or_default(),\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::event::{PushReceivedPayload, EventType, EventPayload};\n\n #[test]\n fn test_serialize_roundtrip() {\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n\n let json = EventSerializer::serialize_to_string(&event).unwrap();\n let parsed: EventEnvelope = EventSerializer::deserialize_from_str(&json).unwrap();\n\n assert_eq!(event.event_id, parsed.event_id);\n assert_eq!(event.event_type, parsed.event_type);\n }\n\n #[test]\n fn test_serialize_to_bytes() {\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n\n let bytes = EventSerializer::serialize(&event).unwrap();\n assert!(!bytes.is_empty());\n\n let parsed: EventEnvelope = EventSerializer::deserialize(&bytes).unwrap();\n assert_eq!(event.event_id, parsed.event_id);\n }\n\n #[test]\n fn test_json_event_from_envelope() {\n let event = EventEnvelope::new(\n EventType::PushReceived,\n EventPayload::PushReceived(PushReceivedPayload {\n repo_id: gitforce_common::RepoId::new(),\n ref_name: \"refs/heads/main\".to_string(),\n old_hash: \"abc123\".to_string(),\n new_hash: \"def456\".to_string(),\n pusher_id: None,\n }),\n None,\n None,\n );\n\n let json_event = JsonEvent::from_envelope(&event);\n assert_eq!(json_event.event_type, \"push.received\");\n assert_eq!(json_event.event_version, 1);\n }\n}\n","traces":[{"line":11,"address":[9673744],"length":1,"stats":{"Line":1}},{"line":12,"address":[2914497],"length":1,"stats":{"Line":1}},{"line":16,"address":[2914400],"length":1,"stats":{"Line":1}},{"line":17,"address":[9673665],"length":1,"stats":{"Line":1}},{"line":21,"address":[2914352],"length":1,"stats":{"Line":1}},{"line":22,"address":[2914373],"length":1,"stats":{"Line":1}},{"line":26,"address":[2914432],"length":1,"stats":{"Line":1}},{"line":27,"address":[9673717],"length":1,"stats":{"Line":1}},{"line":46,"address":[2915264,2914512,2915258],"length":1,"stats":{"Line":1}},{"line":48,"address":[9673822],"length":1,"stats":{"Line":1}},{"line":49,"address":[2914565],"length":1,"stats":{"Line":1}},{"line":50,"address":[9673914],"length":1,"stats":{"Line":1}},{"line":51,"address":[20631028],"length":1,"stats":{"Line":1}},{"line":52,"address":[2914652],"length":1,"stats":{"Line":1}},{"line":53,"address":[9674028],"length":1,"stats":{"Line":1}},{"line":54,"address":[2814540,2814528],"length":1,"stats":{"Line":1}},{"line":55,"address":[2914936,2915000],"length":1,"stats":{"Line":2}}],"covered":17,"coverable":17},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-events","src","types.rs"],"content":"//! Event type aliases for convenience\n//!\n//! Re-exports all event types from the event module.\n\npub use crate::event::{\n EventEnvelope, EventPayload, EventType,\n RepoCreatedPayload, RepoDeletedPayload,\n PushReceivedPayload, RefUpdatedPayload,\n PipelineTriggeredPayload, PipelineStartedPayload, PipelineFinishedPayload,\n JobQueuedPayload, JobStartedPayload, JobFinishedPayload,\n ArtifactCreatedPayload,\n RunnerRegisteredPayload, RunnerHeartbeatPayload, RunnerOfflinePayload,\n MirrorSyncRequestedPayload, MirrorSyncCompletedPayload,\n};\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-runner","src","agent.rs"],"content":"//! Runner agent\n\nuse gitforce_common::{Error, Result, RunnerId};\nuse gitforce_db::models::Runner;\nuse gitforce_sandbox::{DockerSandbox};\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tokio::time::{interval, Duration};\n\n/// Runner configuration\n#[derive(Debug, Clone)]\npub struct RunnerConfig {\n /// Scheduler URL for job fetching\n pub scheduler_url: String,\n /// Runner name\n pub name: String,\n /// Runner type\n pub runner_type: String,\n /// Capacity (number of concurrent jobs)\n pub capacity: i32,\n /// Heartbeat interval in seconds\n pub heartbeat_interval_secs: u64,\n /// Job fetch interval in seconds\n pub fetch_interval_secs: u64,\n}\n\nimpl Default for RunnerConfig {\n fn default() -> Self {\n Self {\n scheduler_url: \"http://localhost:8081\".to_string(),\n name: \"runner\".to_string(),\n runner_type: \"docker\".to_string(),\n capacity: 2,\n heartbeat_interval_secs: 30,\n fetch_interval_secs: 5,\n }\n }\n}\n\n/// Job assignment from scheduler\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JobAssignment {\n /// Job ID\n pub job_id: String,\n /// Job name\n pub name: String,\n /// Pipeline run ID\n pub pipeline_run_id: String,\n /// Commands to execute\n pub commands: Vec,\n /// Working directory\n pub working_dir: Option,\n}\n\n/// Runner agent that fetches and executes jobs\n#[derive(Clone)]\npub struct RunnerAgent {\n config: RunnerConfig,\n client: Client,\n runner: Option,\n #[allow(dead_code)]\n sandbox: Arc,\n is_running: Arc>,\n}\n\nimpl RunnerAgent {\n /// Create a new runner agent\n pub async fn new(config: RunnerConfig) -> Result {\n let client = Client::builder()\n .timeout(Duration::from_secs(30))\n .build()\n .map_err(|e| Error::internal(format!(\"failed to create HTTP client: {}\", e)))?;\n\n let sandbox = DockerSandbox::new().await?;\n\n Ok(Self {\n config,\n client,\n runner: None,\n sandbox: Arc::new(sandbox),\n is_running: Arc::new(RwLock::new(false)),\n })\n }\n\n /// Register with the scheduler via HTTP\n pub async fn register(&mut self) -> Result {\n let runner = Runner::new(\n self.config.name.clone(),\n gitforce_db::models::RunnerType::Docker,\n self.config.capacity,\n );\n\n // Try to register with scheduler via HTTP\n let register_url = format!(\"{}/runners\", self.config.scheduler_url);\n let request = serde_json::json!({\n \"name\": runner.name,\n \"type\": runner.runner_type,\n \"capacity\": runner.capacity,\n });\n\n match self.client.post(®ister_url).json(&request).send().await {\n Ok(response) => {\n if response.status().is_success() {\n tracing::info!(\"registered runner {} with scheduler\", runner.id);\n } else {\n tracing::warn!(\n \"scheduler returned {} for registration, running in standalone mode\",\n response.status()\n );\n }\n }\n Err(e) => {\n tracing::warn!(\n \"failed to register with scheduler: {}. Running in standalone mode.\",\n e\n );\n }\n }\n\n self.runner = Some(runner.clone());\n Ok(runner.id)\n }\n\n /// Start the runner agent loop\n pub async fn run(&self) -> Result<()> {\n *self.is_running.write().await = true;\n\n let runner = self.runner\n .as_ref()\n .ok_or_else(|| Error::internal(\"runner not registered\"))?;\n\n let runner_id = runner.id;\n tracing::info!(\"runner {} starting\", runner_id);\n\n // Start heartbeat loop\n let heartbeat_runner_id = runner_id;\n let heartbeat_interval = self.config.heartbeat_interval_secs;\n let heartbeat_client = self.client.clone();\n let heartbeat_url = self.config.scheduler_url.clone();\n let is_running = self.is_running.clone();\n tokio::spawn(async move {\n let mut ticker = interval(Duration::from_secs(heartbeat_interval));\n loop {\n ticker.tick().await;\n if !*is_running.read().await {\n tracing::debug!(\"heartbeat loop stopping\");\n break;\n }\n tracing::debug!(\"runner {} sending heartbeat\", heartbeat_runner_id);\n let url = format!(\"{}/runners/{}/heartbeat\", heartbeat_url, heartbeat_runner_id);\n if let Err(e) = heartbeat_client.post(&url).send().await {\n tracing::trace!(\"heartbeat failed: {}\", e);\n }\n }\n });\n\n // Start job fetch loop\n let fetch_interval = self.config.fetch_interval_secs;\n let fetch_client = self.client.clone();\n let fetch_url = self.config.scheduler_url.clone();\n let is_running = self.is_running.clone();\n tokio::spawn(async move {\n let mut ticker = interval(Duration::from_secs(fetch_interval));\n loop {\n ticker.tick().await;\n if !*is_running.read().await {\n tracing::debug!(\"job fetch loop stopping\");\n break;\n }\n tracing::debug!(\"runner checking for jobs...\");\n\n let jobs_url = format!(\"{}/jobs/pending\", fetch_url);\n match fetch_client.get(&jobs_url).send().await {\n Ok(response) => {\n if response.status().is_success() {\n if let Ok(jobs) = response.json::>().await {\n for job in jobs {\n tracing::info!(\n \"received job assignment: {} ({})\",\n job.name, job.job_id\n );\n // Job execution would happen here\n }\n }\n }\n }\n Err(e) => {\n tracing::trace!(\"job fetch failed: {}\", e);\n }\n }\n }\n });\n\n // Keep running until stopped\n while *self.is_running.read().await {\n tokio::time::sleep(Duration::from_secs(1)).await;\n }\n\n Ok(())\n }\n\n /// Stop the runner agent\n pub async fn stop(&self) {\n *self.is_running.write().await = false;\n let runner_id = self.runner.as_ref().map(|r| r.id.to_string()).unwrap_or_default();\n tracing::info!(\"runner {} stopped\", runner_id);\n }\n\n /// Check if agent is running\n pub async fn is_running(&self) -> bool {\n *self.is_running.read().await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_runner_creation() {\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n assert!(agent.runner.is_none());\n }\n\n #[tokio::test]\n async fn test_runner_config_default() {\n let config = RunnerConfig::default();\n assert_eq!(config.name, \"runner\");\n assert_eq!(config.capacity, 2);\n assert_eq!(config.heartbeat_interval_secs, 30);\n assert_eq!(config.fetch_interval_secs, 5);\n }\n\n #[tokio::test]\n async fn test_runner_register_no_scheduler() {\n // Test that register doesn't panic when scheduler is unavailable\n let mut config = RunnerConfig::default();\n config.scheduler_url = \"http://localhost:99999\".to_string(); // Invalid URL\n let mut agent = RunnerAgent::new(config).await.unwrap();\n let result = agent.register().await;\n assert!(result.is_ok());\n assert!(agent.runner.is_some());\n }\n\n #[tokio::test]\n async fn test_runner_agent_stop() {\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n agent.stop().await;\n // No panic means success\n }\n\n #[test]\n fn test_runner_config_custom_values() {\n let config = RunnerConfig {\n scheduler_url: \"http://custom:8081\".to_string(),\n name: \"custom-runner\".to_string(),\n runner_type: \"kubernetes\".to_string(),\n capacity: 5,\n heartbeat_interval_secs: 60,\n fetch_interval_secs: 10,\n };\n assert_eq!(config.name, \"custom-runner\");\n assert_eq!(config.capacity, 5);\n assert_eq!(config.heartbeat_interval_secs, 60);\n assert_eq!(config.fetch_interval_secs, 10);\n }\n\n #[test]\n fn test_job_assignment_serialization() {\n let assignment = JobAssignment {\n job_id: \"job-123\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-456\".to_string(),\n commands: vec![\"cargo build\".to_string(), \"cargo test\".to_string()],\n working_dir: Some(\"/workspace\".to_string()),\n };\n\n let json = serde_json::to_string(&assignment).unwrap();\n let deserialized: JobAssignment = serde_json::from_str(&json).unwrap();\n assert_eq!(deserialized.job_id, \"job-123\");\n assert_eq!(deserialized.commands.len(), 2);\n }\n\n #[test]\n fn test_job_assignment_without_working_dir() {\n let assignment = JobAssignment {\n job_id: \"job-456\".to_string(),\n name: \"test\".to_string(),\n pipeline_run_id: \"run-789\".to_string(),\n commands: vec![\"cargo test\".to_string()],\n working_dir: None,\n };\n assert!(assignment.working_dir.is_none());\n }\n\n #[test]\n fn test_runner_config_debug() {\n let config = RunnerConfig::default();\n let debug_str = format!(\"{:?}\", config);\n assert!(debug_str.contains(\"runner\"));\n }\n\n #[test]\n fn test_job_assignment_debug() {\n let assignment = JobAssignment {\n job_id: \"job-123\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-456\".to_string(),\n commands: vec![\"cargo build\".to_string()],\n working_dir: None,\n };\n let debug_str = format!(\"{:?}\", assignment);\n assert!(debug_str.contains(\"job-123\"));\n }\n\n #[test]\n fn test_runner_config_clone() {\n let config = RunnerConfig::default();\n let cloned = config.clone();\n assert_eq!(cloned.name, config.name);\n assert_eq!(cloned.capacity, config.capacity);\n }\n\n #[test]\n fn test_runner_config_partial_eq() {\n let config1 = RunnerConfig::default();\n let config2 = RunnerConfig::default();\n assert_eq!(config1.name, config2.name);\n assert_eq!(config1.capacity, config2.capacity);\n }\n\n #[test]\n fn test_job_assignment_serde_roundtrip() {\n let assignment = JobAssignment {\n job_id: \"job-123\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-456\".to_string(),\n commands: vec![\"cargo build\".to_string(), \"cargo test\".to_string()],\n working_dir: Some(\"/workspace\".to_string()),\n };\n\n // Test JSON serialization\n let json = serde_json::to_string(&assignment).unwrap();\n assert!(json.contains(\"job-123\"));\n assert!(json.contains(\"build\"));\n\n // Test deserialization\n let deserialized: JobAssignment = serde_json::from_str(&json).unwrap();\n assert_eq!(deserialized.job_id, assignment.job_id);\n assert_eq!(deserialized.name, assignment.name);\n assert_eq!(deserialized.commands, assignment.commands);\n assert_eq!(deserialized.working_dir, assignment.working_dir);\n }\n\n #[test]\n fn test_job_assignment_empty_commands() {\n let assignment = JobAssignment {\n job_id: \"job-empty\".to_string(),\n name: \"noop\".to_string(),\n pipeline_run_id: \"run-001\".to_string(),\n commands: vec![],\n working_dir: None,\n };\n assert!(assignment.commands.is_empty());\n assert!(assignment.working_dir.is_none());\n }\n\n #[test]\n fn test_job_assignment_multiple_commands() {\n let commands = vec![\n \"cargo fetch\".to_string(),\n \"cargo build --release\".to_string(),\n \"cargo test\".to_string(),\n \"cargo clippy\".to_string(),\n ];\n let assignment = JobAssignment {\n job_id: \"job-multi\".to_string(),\n name: \"full-pipeline\".to_string(),\n pipeline_run_id: \"run-002\".to_string(),\n commands,\n working_dir: Some(\"/project\".to_string()),\n };\n assert_eq!(assignment.commands.len(), 4);\n }\n\n #[tokio::test]\n async fn test_runner_agent_not_registered() {\n // Agent without registration should have runner as None\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n assert!(agent.runner.is_none());\n }\n\n #[tokio::test]\n async fn test_runner_register_sets_runner() {\n let mut config = RunnerConfig::default();\n config.scheduler_url = \"http://localhost:99999\".to_string();\n let mut agent = RunnerAgent::new(config).await.unwrap();\n\n let result = agent.register().await;\n assert!(result.is_ok());\n assert!(agent.runner.is_some());\n\n // Verify runner has correct properties\n let runner = agent.runner.as_ref().unwrap();\n assert_eq!(runner.name, \"runner\");\n assert_eq!(runner.capacity, 2);\n }\n\n #[test]\n fn test_runner_config_all_fields() {\n let config = RunnerConfig {\n scheduler_url: \"http://example.com:8081\".to_string(),\n name: \"test-runner\".to_string(),\n runner_type: \"firecracker\".to_string(),\n capacity: 8,\n heartbeat_interval_secs: 15,\n fetch_interval_secs: 3,\n };\n\n assert_eq!(config.scheduler_url, \"http://example.com:8081\");\n assert_eq!(config.name, \"test-runner\");\n assert_eq!(config.runner_type, \"firecracker\");\n assert_eq!(config.capacity, 8);\n assert_eq!(config.heartbeat_interval_secs, 15);\n assert_eq!(config.fetch_interval_secs, 3);\n }\n\n #[test]\n fn test_runner_config_default_is_docker() {\n let config = RunnerConfig::default();\n assert_eq!(config.runner_type, \"docker\");\n }\n\n #[test]\n fn test_runner_config_default_heartbeat() {\n let config = RunnerConfig::default();\n // Default heartbeat is 30 seconds\n assert_eq!(config.heartbeat_interval_secs, 30);\n // Default fetch interval is 5 seconds\n assert_eq!(config.fetch_interval_secs, 5);\n }\n\n #[test]\n fn test_runner_agent_debug() {\n // We can't easily create a running agent for debug test\n // but we can verify the type implements Debug\n let config = RunnerConfig::default();\n assert!(format!(\"{:?}\", config).contains(\"RunnerConfig\"));\n }\n\n #[test]\n fn test_job_assignment_equality() {\n let assignment1 = JobAssignment {\n job_id: \"job-1\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-1\".to_string(),\n commands: vec![\"echo 1\".to_string()],\n working_dir: None,\n };\n let assignment2 = JobAssignment {\n job_id: \"job-1\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-1\".to_string(),\n commands: vec![\"echo 1\".to_string()],\n working_dir: None,\n };\n // JobAssignment should implement PartialEq if we add it\n // For now just verify individual field equality\n assert_eq!(assignment1.job_id, assignment2.job_id);\n assert_eq!(assignment1.name, assignment2.name);\n }\n\n #[test]\n fn test_job_assignment_serialize_with_minimal_fields() {\n let assignment = JobAssignment {\n job_id: \"minimal-job\".to_string(),\n name: \"test\".to_string(),\n pipeline_run_id: \"run-min\".to_string(),\n commands: vec![\"true\".to_string()],\n working_dir: None,\n };\n\n let json = serde_json::to_string(&assignment).unwrap();\n assert!(json.contains(\"minimal-job\"));\n assert!(json.contains(\"test\"));\n assert!(json.contains(\"minimal-job\"));\n }\n\n #[tokio::test]\n async fn test_runner_stop_when_not_running() {\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n // Stop without running should not panic\n agent.stop().await;\n }\n\n #[tokio::test]\n async fn test_runner_stop_after_registration() {\n let mut config = RunnerConfig::default();\n config.scheduler_url = \"http://localhost:99999\".to_string();\n let mut agent = RunnerAgent::new(config).await.unwrap();\n agent.register().await.unwrap();\n // Stop after registration should not panic\n agent.stop().await;\n }\n\n #[test]\n fn test_runner_config_all_default_values() {\n let config = RunnerConfig::default();\n // Verify all default values\n assert_eq!(config.scheduler_url, \"http://localhost:8081\");\n assert_eq!(config.name, \"runner\");\n assert_eq!(config.runner_type, \"docker\");\n assert_eq!(config.capacity, 2);\n assert_eq!(config.heartbeat_interval_secs, 30);\n assert_eq!(config.fetch_interval_secs, 5);\n }\n\n #[test]\n fn test_runner_config_with_zero_capacity() {\n let config = RunnerConfig {\n scheduler_url: \"http://localhost:8081\".to_string(),\n name: \"zero-cap\".to_string(),\n runner_type: \"docker\".to_string(),\n capacity: 0,\n heartbeat_interval_secs: 30,\n fetch_interval_secs: 5,\n };\n assert_eq!(config.capacity, 0);\n }\n\n #[test]\n fn test_job_assignment_with_many_commands() {\n let commands: Vec = (0..100).map(|i| format!(\"echo step{}\", i)).collect();\n let assignment = JobAssignment {\n job_id: \"job-many\".to_string(),\n name: \"many-steps\".to_string(),\n pipeline_run_id: \"run-many\".to_string(),\n commands,\n working_dir: None,\n };\n assert_eq!(assignment.commands.len(), 100);\n }\n\n #[test]\n fn test_job_assignment_clone() {\n let assignment = JobAssignment {\n job_id: \"clone-test\".to_string(),\n name: \"test\".to_string(),\n pipeline_run_id: \"run-1\".to_string(),\n commands: vec![\"echo clone\".to_string()],\n working_dir: None,\n };\n let cloned = assignment.clone();\n assert_eq!(cloned.job_id, assignment.job_id);\n assert_eq!(cloned.commands, assignment.commands);\n }\n\n #[test]\n fn test_job_assignment_with_unicode_in_name() {\n let assignment = JobAssignment {\n job_id: \"job-unicode\".to_string(),\n name: \"测试任务\".to_string(),\n pipeline_run_id: \"run-unicode\".to_string(),\n commands: vec![\"echo 测试\".to_string()],\n working_dir: None,\n };\n assert_eq!(assignment.name, \"测试任务\");\n }\n\n #[test]\n fn test_job_assignment_with_special_chars_in_commands() {\n let assignment = JobAssignment {\n job_id: \"special-cmd\".to_string(),\n name: \"special\".to_string(),\n pipeline_run_id: \"run-special\".to_string(),\n commands: vec![\n \"echo $HOME\".to_string(),\n \"echo \\\"quoted\\\"\".to_string(),\n \"echo 'single'\".to_string(),\n ],\n working_dir: None,\n };\n assert_eq!(assignment.commands.len(), 3);\n }\n\n #[test]\n fn test_runner_config_with_special_url() {\n let config = RunnerConfig {\n scheduler_url: \"http://user:pass@host:9090/path\".to_string(),\n name: \"special-url-runner\".to_string(),\n runner_type: \"docker\".to_string(),\n capacity: 4,\n heartbeat_interval_secs: 45,\n fetch_interval_secs: 10,\n };\n assert!(config.scheduler_url.contains(\"user:pass\"));\n }\n\n #[test]\n fn test_job_assignment_deserialize() {\n let json = r#\"{\n \"job_id\": \"deserialized-job\",\n \"name\": \"deserialized\",\n \"pipeline_run_id\": \"run-123\",\n \"commands\": [\"cargo build\", \"cargo test\"],\n \"working_dir\": \"/workspace\"\n }\"#;\n let assignment: JobAssignment = serde_json::from_str(json).unwrap();\n assert_eq!(assignment.job_id, \"deserialized-job\");\n assert_eq!(assignment.commands.len(), 2);\n }\n\n #[tokio::test]\n async fn test_runner_agent_with_custom_config() {\n let config = RunnerConfig {\n scheduler_url: \"http://custom-scheduler:8081\".to_string(),\n name: \"custom-runner\".to_string(),\n runner_type: \"kubernetes\".to_string(),\n capacity: 10,\n heartbeat_interval_secs: 60,\n fetch_interval_secs: 15,\n };\n let agent = RunnerAgent::new(config).await.unwrap();\n assert!(agent.runner.is_none());\n }\n\n #[test]\n fn test_runner_config_clone_is_independent() {\n let config1 = RunnerConfig::default();\n let mut config2 = config1.clone();\n config2.name = \"modified\".to_string();\n assert_ne!(config1.name, config2.name);\n }\n\n #[test]\n fn test_job_assignment_with_empty_working_dir() {\n let assignment = JobAssignment {\n job_id: \"empty-wd\".to_string(),\n name: \"test\".to_string(),\n pipeline_run_id: \"run-1\".to_string(),\n commands: vec![\"echo test\".to_string()],\n working_dir: Some(\"\".to_string()),\n };\n assert!(assignment.working_dir.is_some());\n }\n\n #[tokio::test]\n async fn test_runner_is_running() {\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n assert!(!agent.is_running().await);\n }\n\n #[tokio::test]\n async fn test_runner_run_and_stop() {\n let mut config = RunnerConfig::default();\n config.scheduler_url = \"http://localhost:99999\".to_string();\n\n // Create and register agent\n let mut agent = RunnerAgent::new(config.clone()).await.unwrap();\n agent.register().await.unwrap();\n\n // Clone for use in spawn\n let agent_clone = agent.clone();\n\n // Start run in background\n let run_handle = tokio::spawn(async move {\n agent_clone.run().await\n });\n\n // Wait for start\n tokio::time::sleep(std::time::Duration::from_millis(100)).await;\n\n // Check is_running\n assert!(agent.is_running().await);\n\n // Stop it\n agent.stop().await;\n\n // Give it time to shutdown\n tokio::time::sleep(std::time::Duration::from_millis(200)).await;\n\n // Verify run completed\n let result = run_handle.await.unwrap();\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_runner_run_requires_registration() {\n let config = RunnerConfig::default();\n let agent = RunnerAgent::new(config).await.unwrap();\n\n // Agent is not registered, run should fail\n let result = agent.run().await;\n assert!(result.is_err());\n }\n}\n","traces":[{"line":30,"address":[9775292,9775008,9775298],"length":1,"stats":{"Line":2}},{"line":32,"address":[13841006],"length":1,"stats":{"Line":3}},{"line":33,"address":[13841042],"length":1,"stats":{"Line":3}},{"line":34,"address":[13841098],"length":1,"stats":{"Line":4}},{"line":70,"address":[13787488,13789972,13787538,13787701,13788433],"length":1,"stats":{"Line":8}},{"line":71,"address":[13788002,13787682,13788379,13788102,13787849],"length":1,"stats":{"Line":8}},{"line":72,"address":[9896913,9896858,9896829,9896794,9897440],"length":1,"stats":{"Line":6}},{"line":74,"address":[13788060,13790080,13787983,13790107],"length":1,"stats":{"Line":1}},{"line":76,"address":[13788219,13788470,13788291,13787728],"length":1,"stats":{"Line":10}},{"line":78,"address":[13789651],"length":1,"stats":{"Line":7}},{"line":79,"address":[13789243],"length":1,"stats":{"Line":3}},{"line":80,"address":[13789334],"length":1,"stats":{"Line":7}},{"line":81,"address":[9898379],"length":1,"stats":{"Line":3}},{"line":82,"address":[9898387,9898556],"length":1,"stats":{"Line":10}},{"line":83,"address":[13789628,13789559],"length":1,"stats":{"Line":10}},{"line":88,"address":[9772952,9772944],"length":1,"stats":{"Line":6}},{"line":90,"address":[9925383],"length":1,"stats":{"Line":1}},{"line":92,"address":[9925511],"length":1,"stats":{"Line":2}},{"line":96,"address":[9925630,9925551],"length":1,"stats":{"Line":3}},{"line":97,"address":[9925819,9926151,9926954,9926468,9926398,9925857,9925927,9926982,9925761,9926218],"length":1,"stats":{"Line":3}},{"line":103,"address":[10855815],"length":1,"stats":{"Line":3}},{"line":104,"address":[13818881],"length":1,"stats":{"Line":0}},{"line":105,"address":[9927462,9927395],"length":1,"stats":{"Line":0}},{"line":106,"address":[9929056,9927533],"length":1,"stats":{"Line":0}},{"line":108,"address":[13820269,13819923],"length":1,"stats":{"Line":0}},{"line":114,"address":[13818798],"length":1,"stats":{"Line":2}},{"line":115,"address":[13822444,13818814,13822049],"length":1,"stats":{"Line":4}},{"line":122,"address":[9932004,9930456,9931853],"length":1,"stats":{"Line":3}},{"line":123,"address":[9932188],"length":1,"stats":{"Line":2}},{"line":127,"address":[13790383,13790741,13790494,13790336,13790537,13790558,13793837,13790587],"length":1,"stats":{"Line":5}},{"line":128,"address":[9878609],"length":1,"stats":{"Line":2}},{"line":130,"address":[13793781,13791126,13791296,13791180],"length":1,"stats":{"Line":4}},{"line":132,"address":[9900101,9900172,9920044,9920032],"length":1,"stats":{"Line":4}},{"line":134,"address":[13791405],"length":1,"stats":{"Line":1}},{"line":135,"address":[13791412,13791892],"length":1,"stats":{"Line":2}},{"line":138,"address":[13791821],"length":1,"stats":{"Line":1}},{"line":139,"address":[13791832],"length":1,"stats":{"Line":1}},{"line":140,"address":[13792958,13791856],"length":1,"stats":{"Line":2}},{"line":141,"address":[13793047,13792970],"length":1,"stats":{"Line":2}},{"line":142,"address":[13793141,13793051],"length":1,"stats":{"Line":2}},{"line":143,"address":[13804850,13804893,13805064,13804767,13809576,13807098,13804736,13804914,13793149],"length":1,"stats":{"Line":3}},{"line":144,"address":[13804821,13805006],"length":1,"stats":{"Line":2}},{"line":146,"address":[13804880,13805038,13807343,13807259,13805098],"length":1,"stats":{"Line":5}},{"line":147,"address":[13807583,13805132,13804901,13807703],"length":1,"stats":{"Line":2}},{"line":148,"address":[13808516,13808082],"length":1,"stats":{"Line":0}},{"line":151,"address":[13809652,13810121],"length":1,"stats":{"Line":2}},{"line":152,"address":[13810061,13811094],"length":1,"stats":{"Line":2}},{"line":153,"address":[13968398],"length":1,"stats":{"Line":4}},{"line":154,"address":[13805641,13806036,13805540],"length":1,"stats":{"Line":3}},{"line":160,"address":[13793316],"length":1,"stats":{"Line":1}},{"line":161,"address":[13793337,13793412],"length":1,"stats":{"Line":2}},{"line":162,"address":[13793501,13793424],"length":1,"stats":{"Line":2}},{"line":163,"address":[9902492,9902409],"length":1,"stats":{"Line":2}},{"line":164,"address":[9909124,9902500,9903888,9903664,9904034,9903803,9903846,9903867,9906622,9903710],"length":1,"stats":{"Line":3}},{"line":165,"address":[13794951,13795164],"length":1,"stats":{"Line":2}},{"line":167,"address":[13798185,13795256,13798092,13795017,13795196],"length":1,"stats":{"Line":5}},{"line":168,"address":[13798557,13798434,13795038,13795293],"length":1,"stats":{"Line":2}},{"line":169,"address":[13798945,13799379],"length":1,"stats":{"Line":0}},{"line":172,"address":[13800967,13800521],"length":1,"stats":{"Line":2}},{"line":174,"address":[13800930,13801868],"length":1,"stats":{"Line":2}},{"line":175,"address":[13802523,13802074,13802203,13801996,13795330,13795059],"length":1,"stats":{"Line":4}},{"line":176,"address":[13802602],"length":1,"stats":{"Line":0}},{"line":177,"address":[13802653],"length":1,"stats":{"Line":0}},{"line":178,"address":[9866389],"length":1,"stats":{"Line":0}},{"line":179,"address":[13796069,13795809,13795934],"length":1,"stats":{"Line":0}},{"line":180,"address":[13796238,13796339,13796734],"length":1,"stats":{"Line":0}},{"line":189,"address":[9911228],"length":1,"stats":{"Line":1}},{"line":190,"address":[13803312,13802567,13802917],"length":1,"stats":{"Line":3}},{"line":197,"address":[9878625],"length":1,"stats":{"Line":3}},{"line":198,"address":[13794599,13793910,13790566,13793882],"length":1,"stats":{"Line":3}},{"line":201,"address":[13794555],"length":1,"stats":{"Line":1}},{"line":205,"address":[13838904,13838896],"length":1,"stats":{"Line":5}},{"line":206,"address":[13814284,13814216,13814156,13814425],"length":1,"stats":{"Line":3}},{"line":207,"address":[13816288,13814764,13816304],"length":1,"stats":{"Line":4}},{"line":208,"address":[13815319,13814845,13814924],"length":1,"stats":{"Line":6}},{"line":212,"address":[13786975,13787111,13787479,13786832,13786936,13786857],"length":1,"stats":{"Line":4}},{"line":213,"address":[13786913,13786963,13787019,13787142,13787425],"length":1,"stats":{"Line":3}}],"covered":66,"coverable":77},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-runner","src","executor.rs"],"content":"//! Job executor\n\nuse gitforce_common::{JobId, Result};\nuse gitforce_sandbox::{DockerSandbox, Sandbox, SandboxInstance, SandboxLimits, StepResult};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// Job to execute\n#[derive(Debug, Clone)]\npub struct ExecutableJob {\n pub job_id: JobId,\n pub image: String,\n pub steps: Vec,\n pub env: HashMap,\n pub working_dir: Option,\n pub timeout_secs: u64,\n}\n\nimpl ExecutableJob {\n /// Create a new executable job\n pub fn new(job_id: JobId, image: String) -> Self {\n Self {\n job_id,\n image,\n steps: Vec::new(),\n env: HashMap::new(),\n working_dir: None,\n timeout_secs: 3600,\n }\n }\n\n pub fn with_steps(mut self, steps: Vec) -> Self {\n self.steps = steps;\n self\n }\n\n pub fn with_env(mut self, env: HashMap) -> Self {\n self.env = env;\n self\n }\n\n pub fn with_timeout(mut self, timeout_secs: u64) -> Self {\n self.timeout_secs = timeout_secs;\n self\n }\n}\n\n/// A single step in a job\n#[derive(Debug, Clone)]\npub struct JobStep {\n pub name: String,\n pub run: String,\n pub env: Option>,\n pub working_directory: Option,\n}\n\nimpl JobStep {\n pub fn new(name: &str, run: &str) -> Self {\n Self {\n name: name.to_string(),\n run: run.to_string(),\n env: None,\n working_directory: None,\n }\n }\n}\n\n/// Job execution result\n#[derive(Debug)]\npub struct JobResult {\n pub job_id: JobId,\n pub success: bool,\n pub exit_code: i32,\n pub step_results: Vec,\n pub error: Option,\n}\n\n/// Job executor\npub struct JobExecutor {\n sandbox: Arc,\n active_instances: Arc>>,\n}\n\nimpl JobExecutor {\n /// Create a new job executor\n pub async fn new() -> Result {\n let sandbox = DockerSandbox::new().await?;\n Ok(Self {\n sandbox: Arc::new(sandbox),\n active_instances: Arc::new(RwLock::new(HashMap::new())),\n })\n }\n\n /// Execute a job\n pub async fn execute(&self, job: ExecutableJob) -> JobResult {\n let job_id = job.job_id;\n tracing::info!(\"executing job {}\", job_id);\n\n // Create sandbox instance\n let instance = match self.sandbox.create(job_id, &job.image, SandboxLimits::default()).await {\n Ok(i) => i,\n Err(e) => {\n return JobResult {\n job_id,\n success: false,\n exit_code: -1,\n step_results: Vec::new(),\n error: Some(format!(\"failed to create sandbox: {}\", e)),\n };\n }\n };\n\n // Store active instance\n {\n let mut instances = self.active_instances.write().await;\n instances.insert(job_id, instance.clone());\n }\n\n // Execute steps\n let mut step_results = Vec::new();\n let mut success = true;\n let mut final_exit_code = 0;\n\n for step in &job.steps {\n tracing::debug!(\"executing step: {}\", step.name);\n\n // Build command\n let cmd = vec![\"sh\", \"-c\", &step.run];\n\n // Execute step\n let result = self.sandbox.execute(&instance, &cmd).await;\n\n match result {\n Ok(step_result) => {\n step_results.push(step_result.clone());\n if step_result.exit_code != 0 {\n success = false;\n final_exit_code = step_result.exit_code;\n tracing::error!(\"step {} failed with exit code {}\", step.name, step_result.exit_code);\n break;\n }\n }\n Err(e) => {\n success = false;\n final_exit_code = -1;\n step_results.push(StepResult {\n exit_code: -1,\n stdout: String::new(),\n stderr: format!(\"execution error: {}\", e),\n });\n break;\n }\n }\n }\n\n // Cleanup sandbox\n if let Err(e) = self.sandbox.destroy(instance).await {\n tracing::error!(\"failed to destroy sandbox for job {}: {}\", job_id, e);\n }\n\n // Remove from active instances\n {\n let mut instances = self.active_instances.write().await;\n instances.remove(&job_id);\n }\n\n tracing::info!(\"job {} completed: success={}\", job_id, success);\n\n JobResult {\n job_id,\n success,\n exit_code: final_exit_code,\n step_results,\n error: if success { None } else { Some(\"job failed\".to_string()) },\n }\n }\n\n /// Cancel a running job\n pub async fn cancel(&self, job_id: JobId) -> Result<()> {\n let instances = self.active_instances.read().await;\n if let Some(instance) = instances.get(&job_id) {\n self.sandbox.destroy(instance.clone()).await?;\n }\n Ok(())\n }\n\n /// Get number of active jobs\n pub async fn active_count(&self) -> usize {\n let instances = self.active_instances.read().await;\n instances.len()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_executable_job_creation() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n assert_eq!(job.image, \"rust:latest\");\n assert_eq!(job.timeout_secs, 3600);\n assert!(job.steps.is_empty());\n }\n\n #[test]\n fn test_executable_job_with_steps() {\n let steps = vec![\n JobStep::new(\"build\", \"cargo build\"),\n JobStep::new(\"test\", \"cargo test\"),\n ];\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(steps.clone());\n assert_eq!(job.steps.len(), 2);\n assert_eq!(job.steps[0].name, \"build\");\n }\n\n #[test]\n fn test_executable_job_with_env() {\n let mut env = HashMap::new();\n env.insert(\"RUST_BACKTRACE\".to_string(), \"1\".to_string());\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_env(env);\n assert_eq!(job.env.get(\"RUST_BACKTRACE\"), Some(&\"1\".to_string()));\n }\n\n #[test]\n fn test_executable_job_with_timeout() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_timeout(7200);\n assert_eq!(job.timeout_secs, 7200);\n }\n\n #[test]\n fn test_job_step_creation() {\n let step = JobStep::new(\"build\", \"cargo build --release\");\n assert_eq!(step.name, \"build\");\n assert_eq!(step.run, \"cargo build --release\");\n assert!(step.env.is_none());\n assert!(step.working_directory.is_none());\n }\n\n #[test]\n fn test_job_result_structure() {\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results: vec![],\n error: None,\n };\n assert!(result.success);\n assert_eq!(result.exit_code, 0);\n assert!(result.error.is_none());\n }\n\n #[tokio::test]\n async fn test_active_count_empty() {\n let executor = JobExecutor::new().await.unwrap();\n assert_eq!(executor.active_count().await, 0);\n }\n\n #[test]\n fn test_executable_job_debug() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n let debug_str = format!(\"{:?}\", job);\n assert!(debug_str.contains(\"rust:latest\"));\n }\n\n #[test]\n fn test_job_step_debug() {\n let step = JobStep::new(\"build\", \"cargo build\");\n let debug_str = format!(\"{:?}\", step);\n assert!(debug_str.contains(\"build\"));\n }\n\n #[test]\n fn test_job_result_debug() {\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 1,\n step_results: vec![],\n error: Some(\"failed\".to_string()),\n };\n let debug_str = format!(\"{:?}\", result);\n assert!(debug_str.contains(\"failed\"));\n }\n\n #[test]\n fn test_job_step_with_env() {\n let mut env = HashMap::new();\n env.insert(\"KEY\".to_string(), \"value\".to_string());\n let step = JobStep {\n name: \"test\".to_string(),\n run: \"echo test\".to_string(),\n env: Some(env),\n working_directory: Some(\"/tmp\".to_string()),\n };\n assert_eq!(step.env.as_ref().unwrap().get(\"KEY\"), Some(&\"value\".to_string()));\n assert_eq!(step.working_directory, Some(\"/tmp\".to_string()));\n }\n\n #[test]\n fn test_job_result_with_error() {\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 127,\n step_results: vec![],\n error: Some(\"command not found\".to_string()),\n };\n assert!(!result.success);\n assert_eq!(result.exit_code, 127);\n assert!(result.error.is_some());\n }\n\n #[test]\n fn test_executable_job_clone() {\n let mut env = HashMap::new();\n env.insert(\"FOO\".to_string(), \"bar\".to_string());\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_env(env)\n .with_timeout(1800);\n // Clone to verify all fields are cloneable\n let _ = job.clone();\n }\n\n #[test]\n fn test_executable_job_builder_pattern() {\n let steps = vec![\n JobStep::new(\"setup\", \"cargo fetch\"),\n JobStep::new(\"build\", \"cargo build\"),\n JobStep::new(\"test\", \"cargo test\"),\n ];\n let mut env = HashMap::new();\n env.insert(\"CI\".to_string(), \"true\".to_string());\n\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(steps)\n .with_env(env)\n .with_timeout(7200);\n\n assert_eq!(job.steps.len(), 3);\n assert_eq!(job.env.get(\"CI\"), Some(&\"true\".to_string()));\n assert_eq!(job.timeout_secs, 7200);\n }\n\n #[test]\n fn test_job_step_working_directory() {\n let step = JobStep {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: Some(\"/workspace\".to_string()),\n };\n assert_eq!(step.working_directory, Some(\"/workspace\".to_string()));\n }\n\n #[test]\n fn test_job_result_with_step_results() {\n use gitforce_sandbox::StepResult;\n let step_result = StepResult {\n exit_code: 0,\n stdout: \"Build successful\".to_string(),\n stderr: String::new(),\n };\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results: vec![step_result],\n error: None,\n };\n assert_eq!(result.step_results.len(), 1);\n assert_eq!(result.step_results[0].stdout, \"Build successful\");\n }\n\n #[test]\n fn test_executable_job_with_multiple_steps() {\n let steps = vec![\n JobStep::new(\"step1\", \"echo 1\"),\n JobStep::new(\"step2\", \"echo 2\"),\n JobStep::new(\"step3\", \"echo 3\"),\n ];\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string())\n .with_steps(steps);\n assert_eq!(job.steps.len(), 3);\n }\n\n #[test]\n fn test_executable_job_default_values() {\n let job = ExecutableJob::new(JobId::new(), \"ubuntu:latest\".to_string());\n assert!(job.steps.is_empty());\n assert!(job.env.is_empty());\n assert!(job.working_dir.is_none());\n assert_eq!(job.timeout_secs, 3600);\n }\n\n #[test]\n fn test_job_step_equality() {\n let step1 = JobStep::new(\"build\", \"cargo build\");\n let step2 = JobStep::new(\"build\", \"cargo build\");\n let step3 = JobStep::new(\"test\", \"cargo test\");\n assert_eq!(step1.name, step2.name);\n assert_eq!(step1.run, step2.run);\n assert_ne!(step1.name, step3.name);\n }\n\n #[test]\n fn test_executable_job_env_is_empty_by_default() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n assert!(job.env.is_empty());\n }\n\n #[test]\n fn test_executable_job_with_working_dir() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n // working_dir is not directly settable via builder but exists as a field\n assert!(job.working_dir.is_none());\n }\n\n #[test]\n fn test_job_result_success() {\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results: vec![],\n error: None,\n };\n assert!(result.success);\n assert_eq!(result.exit_code, 0);\n assert!(result.error.is_none());\n }\n\n #[test]\n fn test_job_result_failure_with_stderr() {\n use gitforce_sandbox::StepResult;\n let step_result = StepResult {\n exit_code: 1,\n stdout: String::new(),\n stderr: \"compilation failed\".to_string(),\n };\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 1,\n step_results: vec![step_result],\n error: Some(\"step failed\".to_string()),\n };\n assert!(!result.success);\n assert_eq!(result.exit_code, 1);\n assert!(result.error.is_some());\n }\n\n #[test]\n fn test_executable_job_all_fields() {\n let steps = vec![\n JobStep::new(\"build\", \"cargo build\"),\n JobStep::new(\"test\", \"cargo test\"),\n ];\n let mut env = HashMap::new();\n env.insert(\"RUST_BACKTRACE\".to_string(), \"1\".to_string());\n env.insert(\"CI\".to_string(), \"true\".to_string());\n\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(steps)\n .with_env(env)\n .with_timeout(7200);\n\n assert_eq!(job.image, \"rust:latest\");\n assert_eq!(job.steps.len(), 2);\n assert_eq!(job.env.len(), 2);\n assert_eq!(job.timeout_secs, 7200);\n }\n\n #[test]\n fn test_job_step_all_fields() {\n let mut env = HashMap::new();\n env.insert(\"PATH\".to_string(), \"/usr/bin\".to_string());\n\n let step = JobStep {\n name: \"build\".to_string(),\n run: \"cargo build --release\".to_string(),\n env: Some(env),\n working_directory: Some(\"/project\".to_string()),\n };\n\n assert_eq!(step.name, \"build\");\n assert_eq!(step.run, \"cargo build --release\");\n assert!(step.env.is_some());\n assert_eq!(step.working_directory, Some(\"/project\".to_string()));\n }\n\n #[test]\n fn test_executable_job_id() {\n let job_id = JobId::new();\n let job = ExecutableJob::new(job_id, \"rust:latest\".to_string());\n assert_eq!(job.job_id, job_id);\n }\n\n #[test]\n fn test_executable_job_cloneable() {\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string());\n let cloned = job.clone();\n assert_eq!(cloned.image, job.image);\n assert_eq!(cloned.job_id, job.job_id);\n }\n\n #[test]\n fn test_job_result_cloneable() {\n // JobResult doesn't implement Clone, but we can verify it's Debug\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results: vec![],\n error: None,\n };\n // Verify it can be formatted with Debug\n let debug_str = format!(\"{:?}\", result);\n assert!(debug_str.contains(\"JobResult\"));\n }\n\n #[tokio::test]\n async fn test_executor_active_count_after_create() {\n let executor = JobExecutor::new().await.unwrap();\n // Should start at 0\n let count = executor.active_count().await;\n assert_eq!(count, 0);\n }\n\n #[test]\n fn test_executable_job_with_empty_steps() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(vec![]);\n assert!(job.steps.is_empty());\n }\n\n #[test]\n fn test_executable_job_builder_chaining() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_timeout(3600)\n .with_env(HashMap::new());\n assert_eq!(job.timeout_secs, 3600);\n assert!(job.env.is_empty());\n }\n\n #[test]\n fn test_job_step_clone() {\n let step = JobStep::new(\"build\", \"cargo build\");\n let cloned = step.clone();\n assert_eq!(cloned.name, step.name);\n assert_eq!(cloned.run, step.run);\n }\n\n #[test]\n fn test_executable_job_clone_and_modify() {\n let mut env = HashMap::new();\n env.insert(\"FOO\".to_string(), \"bar\".to_string());\n let job1 = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_env(env);\n let job2 = job1.clone();\n // Verify both have same values\n assert_eq!(job1.image, job2.image);\n assert_eq!(job1.timeout_secs, job2.timeout_secs);\n }\n\n #[test]\n fn test_job_step_from_constructor() {\n let step = JobStep::new(\"build\", \"cargo build --release\");\n assert_eq!(step.name, \"build\");\n assert_eq!(step.run, \"cargo build --release\");\n assert!(step.env.is_none());\n assert!(step.working_directory.is_none());\n }\n\n #[test]\n fn test_executable_job_with_empty_env() {\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string())\n .with_env(HashMap::new());\n assert!(job.env.is_empty());\n }\n\n #[test]\n fn test_executable_job_very_long_timeout() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_timeout(86400); // 24 hours\n assert_eq!(job.timeout_secs, 86400);\n }\n\n #[test]\n fn test_executable_job_zero_timeout() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_timeout(0);\n assert_eq!(job.timeout_secs, 0);\n }\n\n #[test]\n fn test_job_step_clone_is_independent() {\n let step1 = JobStep::new(\"build\", \"cargo build\");\n let step2 = step1.clone();\n // Modify step2\n let step2_modified = JobStep {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n };\n assert_ne!(step1.name, step2_modified.name);\n assert_eq!(step1.name, step2.name);\n }\n\n #[test]\n fn test_executable_job_with_unicode_in_env() {\n let mut env = HashMap::new();\n env.insert(\"中文\".to_string(), \"value\".to_string());\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_env(env);\n assert_eq!(job.env.get(\"中文\"), Some(&\"value\".to_string()));\n }\n\n #[test]\n fn test_executable_job_with_unicode_in_name() {\n let job = ExecutableJob::new(JobId::new(), \"rust:最新\".to_string());\n assert_eq!(job.image, \"rust:最新\");\n }\n\n #[test]\n fn test_executable_job_env_keys_with_special_chars() {\n let mut env = HashMap::new();\n env.insert(\"MY_VAR_123\".to_string(), \"value\".to_string());\n env.insert(\"ANOTHER_VAR\".to_string(), \"another\".to_string());\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_env(env);\n assert_eq!(job.env.len(), 2);\n }\n\n #[tokio::test]\n async fn test_executor_cancel_nonexistent_job() {\n let executor = JobExecutor::new().await.unwrap();\n // Cancel a job that doesn't exist should not error\n let result = executor.cancel(JobId::new()).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_executor_active_count_initial() {\n let executor = JobExecutor::new().await.unwrap();\n assert_eq!(executor.active_count().await, 0);\n }\n\n #[test]\n fn test_executable_job_clone_preserves_id() {\n let job_id = JobId::new();\n let job = ExecutableJob::new(job_id, \"rust:latest\".to_string());\n let cloned = job.clone();\n assert_eq!(cloned.job_id, job_id);\n }\n\n #[test]\n fn test_executable_job_preserves_timeout() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_timeout(7200);\n assert_eq!(job.timeout_secs, 7200);\n }\n\n #[test]\n fn test_job_step_with_both_env_and_working_dir() {\n let mut env = HashMap::new();\n env.insert(\"HOME\".to_string(), \"/root\".to_string());\n let step = JobStep {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: Some(env),\n working_directory: Some(\"/project\".to_string()),\n };\n assert!(step.env.is_some());\n assert!(step.working_directory.is_some());\n }\n\n #[tokio::test]\n #[ignore] // Requires Docker and is slow - run manually with `cargo test -- --ignored`\n async fn test_executor_execute_simple_job() {\n let executor = JobExecutor::new().await.unwrap();\n\n // Skip if Docker not available\n if !executor.sandbox.is_available() {\n return;\n }\n\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string())\n .with_steps(vec![\n JobStep::new(\"test\", \"echo hello\"),\n ]);\n\n let result = executor.execute(job).await;\n // Job completed - success or failure depends on Docker availability\n assert!(result.exit_code == 0 || !result.success || result.error.is_some());\n }\n\n #[tokio::test]\n #[ignore] // Requires Docker and is slow - run manually with `cargo test -- --ignored`\n async fn test_executor_execute_with_env() {\n let executor = JobExecutor::new().await.unwrap();\n\n // Skip if Docker not available\n if !executor.sandbox.is_available() {\n return;\n }\n\n let mut env = HashMap::new();\n env.insert(\"TEST_VAR\".to_string(), \"test_value\".to_string());\n\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string())\n .with_steps(vec![\n JobStep::new(\"env\", \"echo $TEST_VAR\"),\n ])\n .with_env(env);\n\n let result = executor.execute(job).await;\n // Should have executed regardless of outcome\n assert!(result.exit_code == 0 || !result.success);\n }\n\n #[tokio::test]\n async fn test_executor_active_count_after_execute() {\n let executor = JobExecutor::new().await.unwrap();\n\n // Initially 0\n assert_eq!(executor.active_count().await, 0);\n\n let job = ExecutableJob::new(JobId::new(), \"alpine:latest\".to_string())\n .with_steps(vec![JobStep::new(\"test\", \"echo hello\")]);\n\n // Execute (may succeed or fail depending on Docker)\n let _ = executor.execute(job).await;\n\n // After execute, active count should be 0 (job completes)\n assert_eq!(executor.active_count().await, 0);\n }\n\n #[tokio::test]\n async fn test_executor_cancel_idempotent() {\n let executor = JobExecutor::new().await.unwrap();\n let job_id = JobId::new();\n\n // Cancel non-existent job should not error\n let result = executor.cancel(job_id).await;\n assert!(result.is_ok());\n }\n\n #[test]\n fn test_job_result_with_step_results_struct() {\n use gitforce_sandbox::StepResult;\n let step_results = vec![\n StepResult {\n exit_code: 0,\n stdout: \"step1 output\".to_string(),\n stderr: String::new(),\n },\n StepResult {\n exit_code: 0,\n stdout: \"step2 output\".to_string(),\n stderr: String::new(),\n },\n ];\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results,\n error: None,\n };\n assert_eq!(result.step_results.len(), 2);\n assert!(result.success);\n }\n\n #[test]\n fn test_job_result_failure_with_error_message() {\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 1,\n step_results: vec![],\n error: Some(\"step 2 failed: command not found\".to_string()),\n };\n assert!(!result.success);\n assert_eq!(result.exit_code, 1);\n assert!(result.error.is_some());\n }\n\n #[test]\n fn test_job_result_multiple_step_results() {\n use gitforce_sandbox::StepResult;\n let step_results = vec![\n StepResult { exit_code: 0, stdout: \"build success\".to_string(), stderr: String::new() },\n StepResult { exit_code: 0, stdout: \"test success\".to_string(), stderr: String::new() },\n StepResult { exit_code: 0, stdout: \"deploy success\".to_string(), stderr: String::new() },\n ];\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results,\n error: None,\n };\n assert_eq!(result.step_results.len(), 3);\n }\n\n #[test]\n fn test_executable_job_with_working_dir_field() {\n let mut job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n job.working_dir = Some(\"/workspace\".to_string());\n assert_eq!(job.working_dir, Some(\"/workspace\".to_string()));\n }\n\n #[test]\n fn test_executable_job_all_builder_methods() {\n let steps = vec![JobStep::new(\"build\", \"cargo build\")];\n let mut env = HashMap::new();\n env.insert(\"KEY\".to_string(), \"value\".to_string());\n\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(steps)\n .with_env(env)\n .with_timeout(7200);\n\n assert_eq!(job.image, \"rust:latest\");\n assert_eq!(job.steps.len(), 1);\n assert_eq!(job.env.get(\"KEY\"), Some(&\"value\".to_string()));\n assert_eq!(job.timeout_secs, 7200);\n }\n\n #[test]\n fn test_job_step_with_all_fields() {\n let mut env = HashMap::new();\n env.insert(\"VAR\".to_string(), \"val\".to_string());\n\n let step = JobStep {\n name: \"full-step\".to_string(),\n run: \"echo test\".to_string(),\n env: Some(env),\n working_directory: Some(\"/dir\".to_string()),\n };\n assert_eq!(step.name, \"full-step\");\n assert_eq!(step.env.as_ref().unwrap().get(\"VAR\"), Some(&\"val\".to_string()));\n assert_eq!(step.working_directory, Some(\"/dir\".to_string()));\n }\n\n #[test]\n fn test_job_result_debug_format() {\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 127,\n step_results: vec![],\n error: Some(\"not found\".to_string()),\n };\n let debug_str = format!(\"{:?}\", result);\n assert!(debug_str.contains(\"JobResult\"));\n assert!(debug_str.contains(\"127\"));\n }\n}\n","traces":[{"line":22,"address":[13775808,13776141],"length":1,"stats":{"Line":3}},{"line":26,"address":[10012636],"length":1,"stats":{"Line":3}},{"line":27,"address":[13775904],"length":1,"stats":{"Line":3}},{"line":33,"address":[13775584,13775741],"length":1,"stats":{"Line":2}},{"line":34,"address":[13775698,13775616],"length":1,"stats":{"Line":3}},{"line":35,"address":[13775721],"length":1,"stats":{"Line":1}},{"line":38,"address":[13776160,13776321],"length":1,"stats":{"Line":2}},{"line":39,"address":[13776192,13776277],"length":1,"stats":{"Line":4}},{"line":40,"address":[13776301],"length":1,"stats":{"Line":1}},{"line":43,"address":[13775760],"length":1,"stats":{"Line":1}},{"line":44,"address":[13775777],"length":1,"stats":{"Line":1}},{"line":45,"address":[10012568],"length":1,"stats":{"Line":1}},{"line":59,"address":[13776352,13776659,13776653],"length":1,"stats":{"Line":2}},{"line":61,"address":[13776411],"length":1,"stats":{"Line":2}},{"line":62,"address":[13776450],"length":1,"stats":{"Line":3}},{"line":87,"address":[13775459,13775456],"length":1,"stats":{"Line":4}},{"line":88,"address":[13755265,13755367,13755203,13755149],"length":1,"stats":{"Line":3}},{"line":89,"address":[13756400],"length":1,"stats":{"Line":1}},{"line":90,"address":[13756109,13756271],"length":1,"stats":{"Line":2}},{"line":91,"address":[13756279,13756339],"length":1,"stats":{"Line":2}},{"line":96,"address":[9783456,9786488,9783294,9783562,9785390,9783232,9783499,9786587,9783541,9783520],"length":1,"stats":{"Line":6}},{"line":97,"address":[13758243],"length":1,"stats":{"Line":1}},{"line":98,"address":[13758883,13758473,13758257],"length":1,"stats":{"Line":3}},{"line":101,"address":[13759964,13758318,13758847,13760276],"length":1,"stats":{"Line":3}},{"line":102,"address":[13760655],"length":1,"stats":{"Line":1}},{"line":103,"address":[13760562],"length":1,"stats":{"Line":0}},{"line":104,"address":[13761225],"length":1,"stats":{"Line":0}},{"line":105,"address":[13760610],"length":1,"stats":{"Line":0}},{"line":108,"address":[13760625],"length":1,"stats":{"Line":0}},{"line":109,"address":[13761077,13761009],"length":1,"stats":{"Line":0}},{"line":116,"address":[13761497,13760755,13760841,13758339],"length":1,"stats":{"Line":2}},{"line":117,"address":[13761827,13761748],"length":1,"stats":{"Line":2}},{"line":121,"address":[13761953],"length":1,"stats":{"Line":1}},{"line":122,"address":[9787089],"length":1,"stats":{"Line":1}},{"line":123,"address":[13762035],"length":1,"stats":{"Line":1}},{"line":125,"address":[13762999,13762045,13762151,13762173],"length":1,"stats":{"Line":4}},{"line":126,"address":[13763061,13763133,13763528],"length":1,"stats":{"Line":3}},{"line":129,"address":[13764575,13763499],"length":1,"stats":{"Line":2}},{"line":132,"address":[13762210,13764865,13758360,13762244],"length":1,"stats":{"Line":3}},{"line":134,"address":[13762552],"length":1,"stats":{"Line":1}},{"line":135,"address":[9787743],"length":1,"stats":{"Line":1}},{"line":136,"address":[9787807,9787904],"length":1,"stats":{"Line":2}},{"line":137,"address":[13762874],"length":1,"stats":{"Line":1}},{"line":138,"address":[9787948],"length":1,"stats":{"Line":0}},{"line":139,"address":[9787955],"length":1,"stats":{"Line":0}},{"line":140,"address":[9790500,9787968,9790105],"length":1,"stats":{"Line":0}},{"line":144,"address":[9787638],"length":1,"stats":{"Line":0}},{"line":145,"address":[13762645],"length":1,"stats":{"Line":0}},{"line":146,"address":[13762652],"length":1,"stats":{"Line":0}},{"line":147,"address":[13766935,13762662],"length":1,"stats":{"Line":0}},{"line":149,"address":[9787725],"length":1,"stats":{"Line":0}},{"line":150,"address":[9791742,9791806],"length":1,"stats":{"Line":0}},{"line":158,"address":[13767067,13767277,13763097,13758381],"length":1,"stats":{"Line":3}},{"line":159,"address":[13767603,13767723,13768118],"length":1,"stats":{"Line":0}},{"line":164,"address":[9794238,9792610,9783570,9794339],"length":1,"stats":{"Line":2}},{"line":165,"address":[13769734,13769647],"length":1,"stats":{"Line":2}},{"line":168,"address":[9794709,9795254],"length":1,"stats":{"Line":2}},{"line":175,"address":[9795229,9796322,9796413],"length":1,"stats":{"Line":1}},{"line":180,"address":[13775472,13775480],"length":1,"stats":{"Line":4}},{"line":181,"address":[13756914,13756648,13756702,13756785],"length":1,"stats":{"Line":2}},{"line":182,"address":[13757150,13757981,13757225],"length":1,"stats":{"Line":2}},{"line":183,"address":[13757389,13756723,13757315,13757519],"length":1,"stats":{"Line":0}},{"line":185,"address":[9782554],"length":1,"stats":{"Line":1}},{"line":189,"address":[13754368,13754469,13754393,13754508,13754644,13755046],"length":1,"stats":{"Line":4}},{"line":190,"address":[13754552,13754496,13754675,13754449],"length":1,"stats":{"Line":2}},{"line":191,"address":[13754968,13754901],"length":1,"stats":{"Line":2}}],"covered":50,"coverable":66},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-runner","src","lib.rs"],"content":"//! GitForce Runner\n//!\n//! Job execution agent that runs in Docker containers.\n\npub mod agent;\npub mod executor;\n\npub use agent::{RunnerAgent, RunnerConfig};\npub use executor::JobExecutor;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-sandbox","src","docker.rs"],"content":"//! Docker-based sandbox implementation using bollard\n\nuse crate::limits::SandboxLimits;\nuse async_trait::async_trait;\nuse bollard::container::{\n Config, CreateContainerOptions, LogOutput, RemoveContainerOptions,\n StartContainerOptions,\n};\nuse bollard::image::CreateImageOptions;\nuse bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};\nuse bollard::Docker;\nuse futures_util::StreamExt;\nuse gitforce_common::{Error, JobId, Result};\n\n/// Sandbox instance handle\n#[derive(Debug, Clone)]\npub struct SandboxInstance {\n pub container_id: String,\n pub job_id: JobId,\n}\n\n/// Result of step execution\n#[derive(Debug, Clone)]\npub struct StepResult {\n pub exit_code: i32,\n pub stdout: String,\n pub stderr: String,\n}\n\n/// Docker-based sandbox\npub struct DockerSandbox {\n docker: Option,\n #[allow(dead_code)]\n default_limits: SandboxLimits,\n}\n\nimpl DockerSandbox {\n /// Create a new Docker sandbox (connects to Docker daemon)\n pub async fn new() -> Result {\n let docker = match Docker::connect_with_local_defaults() {\n Ok(d) => {\n // Verify connection by pinging Docker\n match d.ping().await {\n Ok(_) => {\n tracing::info!(\"Connected to Docker daemon\");\n Some(d)\n }\n Err(e) => {\n tracing::warn!(\"Docker daemon not available: {}. Running in stub mode.\", e);\n None\n }\n }\n }\n Err(e) => {\n tracing::warn!(\"Failed to connect to Docker: {}. Running in stub mode.\", e);\n None\n }\n };\n\n Ok(Self {\n docker,\n default_limits: SandboxLimits::default(),\n })\n }\n\n /// Create a new Docker sandbox with custom limits\n pub fn with_limits(limits: SandboxLimits) -> Self {\n Self {\n docker: None,\n default_limits: limits,\n }\n }\n\n /// Check if Docker is available\n pub fn is_available(&self) -> bool {\n self.docker.is_some()\n }\n\n /// Pull an image if not present\n async fn ensure_image(&self, image: &str) -> Result<()> {\n if let Some(ref docker) = self.docker {\n tracing::info!(\"Pulling image: {}\", image);\n // Pull the image - Docker is idempotent, so this works even if image exists\n let mut stream = docker.create_image(\n Some(CreateImageOptions {\n from_image: image,\n ..Default::default()\n }),\n None,\n None,\n );\n\n while let Some(result) = stream.next().await {\n match result {\n Ok(info) => {\n tracing::debug!(\"Pull progress: {:?}\", info);\n }\n Err(e) => {\n return Err(Error::sandbox(format!(\"failed to pull image: {}\", e)));\n }\n }\n }\n }\n Ok(())\n }\n}\n\n/// Sandbox trait\n#[async_trait]\npub trait Sandbox: Send + Sync {\n /// Create a new sandbox instance\n async fn create(&self, job_id: JobId, image: &str, limits: SandboxLimits) -> Result;\n\n /// Execute a command in the sandbox\n async fn execute(&self, instance: &SandboxInstance, command: &[&str]) -> Result;\n\n /// Destroy a sandbox instance\n async fn destroy(&self, instance: SandboxInstance) -> Result<()>;\n}\n\n#[async_trait]\nimpl Sandbox for DockerSandbox {\n async fn create(&self, job_id: JobId, image: &str, _limits: SandboxLimits) -> Result {\n if let Some(ref docker) = self.docker {\n // Ensure image is available\n self.ensure_image(image).await?;\n\n let container_name = format!(\"gitforce-job-{}\", job_id);\n\n // Create container\n let config = Config {\n image: Some(image),\n cmd: Some(vec![\"sleep\", \"3600\"]), // Keep container alive\n host_config: None,\n ..Default::default()\n };\n\n let options = CreateContainerOptions {\n name: &container_name,\n platform: None,\n };\n\n let response = docker.create_container(Some(options), config).await\n .map_err(|e| Error::sandbox(format!(\"failed to create container: {}\", e)))?;\n\n // Start container\n docker.start_container(&response.id, None::>).await\n .map_err(|e| Error::sandbox(format!(\"failed to start container: {}\", e)))?;\n\n tracing::info!(\"Created container {} for job {}\", response.id, job_id);\n\n Ok(SandboxInstance {\n container_id: response.id,\n job_id,\n })\n } else {\n // Stub mode - no Docker available\n Ok(SandboxInstance {\n container_id: format!(\"gitforce-job-{}\", job_id),\n job_id,\n })\n }\n }\n\n async fn execute(&self, instance: &SandboxInstance, command: &[&str]) -> Result {\n if let Some(ref docker) = self.docker {\n // Create exec instance\n let config = CreateExecOptions {\n attach_stdout: Some(true),\n attach_stderr: Some(true),\n cmd: Some(command.to_vec()),\n ..Default::default()\n };\n\n let exec = docker.create_exec(&instance.container_id, config).await\n .map_err(|e| Error::sandbox(format!(\"failed to create exec: {}\", e)))?;\n\n // Start exec and get results\n let result = docker.start_exec(&exec.id, None::).await\n .map_err(|e| Error::sandbox(format!(\"failed to start exec: {}\", e)))?;\n\n let mut stdout = String::new();\n let mut stderr = String::new();\n let mut exit_code = 0i32;\n\n if let StartExecResults::Attached { mut output, .. } = result {\n while let Some(item) = output.next().await {\n match item {\n Ok(LogOutput::StdOut { message }) => {\n stdout.push_str(&String::from_utf8_lossy(&message));\n }\n Ok(LogOutput::StdErr { message }) => {\n stderr.push_str(&String::from_utf8_lossy(&message));\n }\n Ok(_) => {}\n Err(e) => {\n tracing::warn!(\"exec output error: {}\", e);\n }\n }\n }\n }\n\n // Inspect to get exit code\n match docker.inspect_exec(&exec.id).await {\n Ok(inspect) => {\n exit_code = inspect.exit_code.unwrap_or(0) as i32;\n }\n Err(e) => {\n tracing::warn!(\"failed to inspect exec: {}\", e);\n }\n }\n\n Ok(StepResult {\n exit_code,\n stdout,\n stderr,\n })\n } else {\n // Stub mode\n tracing::debug!(\"Executing command in stub mode: {:?}\", command);\n Ok(StepResult {\n exit_code: 0,\n stdout: format!(\"Executing: {:?}\\n\", command),\n stderr: String::new(),\n })\n }\n }\n\n async fn destroy(&self, instance: SandboxInstance) -> Result<()> {\n if let Some(ref docker) = self.docker {\n // Stop and remove container\n let options = RemoveContainerOptions {\n force: true,\n ..Default::default()\n };\n\n docker.remove_container(&instance.container_id, Some(options)).await\n .map_err(|e| Error::sandbox(format!(\"failed to remove container: {}\", e)))?;\n\n tracing::info!(\"Destroyed container {} for job {}\", instance.container_id, instance.job_id);\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_docker_sandbox_creation() {\n let sandbox = DockerSandbox::new().await.unwrap();\n\n // Should always succeed even without Docker (stub mode)\n let sandbox2 = DockerSandbox::with_limits(SandboxLimits::default());\n assert!(!sandbox2.is_available() || sandbox.is_available()); // Either stub or real\n }\n\n #[tokio::test]\n #[ignore] // Requires Docker and is slow - run manually with `cargo test -- --ignored`\n async fn test_docker_sandbox_real_when_available() {\n let sandbox = DockerSandbox::new().await.unwrap();\n\n if !sandbox.is_available() {\n // Skip if Docker not available - this is expected in CI\n return;\n }\n\n // Test with real Docker\n let job_id = JobId::new();\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await;\n\n // If Docker has issues, we fall back to stub mode\n if instance.is_err() {\n return;\n }\n\n let instance = instance.unwrap();\n assert!(!instance.container_id.is_empty());\n\n let result = sandbox.execute(&instance, &[\"echo\", \"hello\"]).await;\n if result.is_ok() {\n assert_eq!(result.unwrap().exit_code, 0);\n }\n\n let _ = sandbox.destroy(instance).await;\n }\n\n #[tokio::test]\n #[ignore] // Requires Docker and is slow - run manually with `cargo test -- --ignored`\n async fn test_docker_sandbox_real_with_longer_command() {\n let sandbox = DockerSandbox::new().await.unwrap();\n\n if !sandbox.is_available() {\n return;\n }\n\n let job_id = JobId::new();\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await;\n if instance.is_err() {\n return;\n }\n let instance = instance.unwrap();\n\n let result = sandbox.execute(&instance, &[\"sh\", \"-c\", \"echo test\"]).await;\n if result.is_ok() {\n assert_eq!(result.unwrap().exit_code, 0);\n }\n\n let _ = sandbox.destroy(instance).await;\n }\n\n #[tokio::test]\n #[ignore] // Requires Docker and is slow - run manually with `cargo test -- --ignored`\n async fn test_docker_sandbox_real_multiple_commands() {\n let sandbox = DockerSandbox::new().await.unwrap();\n\n if !sandbox.is_available() {\n return;\n }\n\n let job_id = JobId::new();\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await;\n if instance.is_err() {\n return;\n }\n let instance = instance.unwrap();\n\n let result = sandbox.execute(&instance, &[\"sh\", \"-c\", \"echo line1\"]).await;\n if result.is_ok() {\n assert_eq!(result.unwrap().exit_code, 0);\n }\n\n let _ = sandbox.destroy(instance).await;\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execution() {\n // Create sandbox in stub mode\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n assert!(!sandbox.is_available());\n\n let job_id = JobId::new();\n\n // Create container (will use stub)\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n assert!(!instance.container_id.is_empty());\n\n // Execute command (will use stub)\n let result = sandbox.execute(&instance, &[\"echo\", \"hello\"]).await.unwrap();\n assert_eq!(result.exit_code, 0);\n\n // Destroy container (will use stub)\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[test]\n fn test_sandbox_instance_debug() {\n let instance = SandboxInstance {\n container_id: \"test-container\".to_string(),\n job_id: JobId::new(),\n };\n let debug_str = format!(\"{:?}\", instance);\n assert!(debug_str.contains(\"test-container\"));\n }\n\n #[test]\n fn test_step_result_debug() {\n let result = StepResult {\n exit_code: 0,\n stdout: \"hello\".to_string(),\n stderr: String::new(),\n };\n let debug_str = format!(\"{:?}\", result);\n assert!(debug_str.contains(\"hello\"));\n }\n\n #[test]\n fn test_docker_sandbox_with_custom_limits() {\n let limits = SandboxLimits::small();\n let sandbox = DockerSandbox::with_limits(limits);\n assert!(!sandbox.is_available());\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execution_with_complex_command() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n\n // Test with shell command\n let result = sandbox.execute(&instance, &[\"sh\", \"-c\", \"echo hello && echo world\"]).await.unwrap();\n assert_eq!(result.exit_code, 0);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execution_multiple_commands() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n\n // Multiple commands in one execution\n let result = sandbox.execute(&instance, &[\"echo\", \"line1\"]).await.unwrap();\n assert_eq!(result.exit_code, 0);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[test]\n fn test_sandbox_instance_clone() {\n let instance = SandboxInstance {\n container_id: \"clone-test\".to_string(),\n job_id: JobId::new(),\n };\n let cloned = instance.clone();\n assert_eq!(cloned.container_id, instance.container_id);\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_preserves_job_id() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n assert_eq!(instance.job_id, job_id);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execute_empty_command() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n\n // Empty command should still work in stub mode\n let result = sandbox.execute(&instance, &[]).await.unwrap();\n assert_eq!(result.exit_code, 0);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execute_single_command() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"rust:latest\", SandboxLimits::default()).await.unwrap();\n\n let result = sandbox.execute(&instance, &[\"true\"]).await.unwrap();\n assert_eq!(result.exit_code, 0);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_execute_false_command() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n\n let result = sandbox.execute(&instance, &[\"false\"]).await.unwrap();\n // Stub mode always returns 0, even for false command\n assert_eq!(result.exit_code, 0);\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_multiple_instances() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n\n // Create multiple instances\n let job1 = JobId::new();\n let job2 = JobId::new();\n let job3 = JobId::new();\n\n let instance1 = sandbox.create(job1, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n let instance2 = sandbox.create(job2, \"ubuntu:latest\", SandboxLimits::default()).await.unwrap();\n let instance3 = sandbox.create(job3, \"rust:latest\", SandboxLimits::default()).await.unwrap();\n\n // Each should have unique container ID\n assert_ne!(instance1.container_id, instance2.container_id);\n assert_ne!(instance2.container_id, instance3.container_id);\n\n sandbox.destroy(instance1).await.unwrap();\n sandbox.destroy(instance2).await.unwrap();\n sandbox.destroy(instance3).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_stub_destroy_same_instance_twice() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n let instance = sandbox.create(job_id, \"alpine:latest\", SandboxLimits::default()).await.unwrap();\n\n // Destroy twice should both succeed (idempotent in stub mode)\n sandbox.destroy(instance.clone()).await.unwrap();\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[tokio::test]\n async fn test_docker_sandbox_with_large_image_name() {\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n let job_id = JobId::new();\n\n // Very long image name\n let instance = sandbox.create(job_id, \"registry.example.com/verylongnamed repository/imagename:latest\", SandboxLimits::default()).await.unwrap();\n assert!(!instance.container_id.is_empty());\n\n sandbox.destroy(instance).await.unwrap();\n }\n\n #[test]\n fn test_docker_sandbox_debug_trait_not_implemented() {\n // DockerSandbox doesn't implement Debug, which is intentional\n // This test documents that behavior\n let sandbox = DockerSandbox::with_limits(SandboxLimits::default());\n assert!(!sandbox.is_available());\n }\n\n #[test]\n fn test_step_result_with_stderr() {\n let result = StepResult {\n exit_code: 1,\n stdout: String::new(),\n stderr: \"error message\".to_string(),\n };\n assert_eq!(result.exit_code, 1);\n assert_eq!(result.stderr, \"error message\");\n }\n\n #[test]\n fn test_step_result_clone() {\n let result = StepResult {\n exit_code: 0,\n stdout: \"hello\".to_string(),\n stderr: String::new(),\n };\n let cloned = result.clone();\n assert_eq!(cloned.stdout, result.stdout);\n assert_eq!(cloned.exit_code, result.exit_code);\n }\n}\n","traces":[{"line":39,"address":[7045024,7045027],"length":1,"stats":{"Line":22}},{"line":40,"address":[17156830,17156695],"length":1,"stats":{"Line":10}},{"line":41,"address":[17156934],"length":1,"stats":{"Line":6}},{"line":43,"address":[13675284,13675055,13677065,13675351],"length":1,"stats":{"Line":16}},{"line":45,"address":[13678032,13677492,13677583],"length":1,"stats":{"Line":15}},{"line":46,"address":[7017789],"length":1,"stats":{"Line":8}},{"line":48,"address":[13677397],"length":1,"stats":{"Line":0}},{"line":49,"address":[17159157,17160716,17161137],"length":1,"stats":{"Line":0}},{"line":50,"address":[13679386],"length":1,"stats":{"Line":0}},{"line":54,"address":[13675151],"length":1,"stats":{"Line":0}},{"line":55,"address":[13675199,13675493,13675914],"length":1,"stats":{"Line":0}},{"line":56,"address":[7015723],"length":1,"stats":{"Line":0}},{"line":60,"address":[7020327],"length":1,"stats":{"Line":9}},{"line":61,"address":[13676931],"length":1,"stats":{"Line":5}},{"line":62,"address":[17158699],"length":1,"stats":{"Line":8}},{"line":67,"address":[17036256],"length":1,"stats":{"Line":12}},{"line":75,"address":[17036400],"length":1,"stats":{"Line":3}},{"line":76,"address":[13554853],"length":1,"stats":{"Line":3}},{"line":80,"address":[13669600,13669770,13669888,13671864,13673817,13669647],"length":1,"stats":{"Line":4}},{"line":81,"address":[13669821,13669736],"length":1,"stats":{"Line":2}},{"line":82,"address":[7009870,7009765,7010293],"length":1,"stats":{"Line":3}},{"line":84,"address":[13671736],"length":1,"stats":{"Line":1}},{"line":85,"address":[7011346],"length":1,"stats":{"Line":1}},{"line":86,"address":[13670308],"length":1,"stats":{"Line":1}},{"line":87,"address":[13670332],"length":1,"stats":{"Line":1}},{"line":89,"address":[7011634],"length":1,"stats":{"Line":1}},{"line":90,"address":[7011656],"length":1,"stats":{"Line":1}},{"line":93,"address":[13669800,13671838,13671977,13673779,13671898],"length":1,"stats":{"Line":5}},{"line":94,"address":[17153857],"length":1,"stats":{"Line":1}},{"line":95,"address":[7012247],"length":1,"stats":{"Line":1}},{"line":96,"address":[17154048,17154127,17154522],"length":1,"stats":{"Line":3}},{"line":98,"address":[17153916],"length":1,"stats":{"Line":0}},{"line":99,"address":[7013748,7012193],"length":1,"stats":{"Line":0}},{"line":104,"address":[7009793],"length":1,"stats":{"Line":1}},{"line":123,"address":[17037183],"length":1,"stats":{"Line":57}},{"line":124,"address":[7022517,7022410],"length":1,"stats":{"Line":12}},{"line":126,"address":[7022648,7022228,7022521,7025287,7023062],"length":1,"stats":{"Line":3}},{"line":128,"address":[7023540],"length":1,"stats":{"Line":1}},{"line":132,"address":[13683939],"length":1,"stats":{"Line":1}},{"line":133,"address":[7023719,7023797],"length":1,"stats":{"Line":2}},{"line":139,"address":[17166998],"length":1,"stats":{"Line":1}},{"line":143,"address":[17167033,17167609,17164153,17167702,17168147,17167270],"length":1,"stats":{"Line":3}},{"line":144,"address":[13685886,13688800,13685958,13688822],"length":1,"stats":{"Line":1}},{"line":147,"address":[17168190,17164174,17167898,17168018,17168644,17168528],"length":1,"stats":{"Line":4}},{"line":148,"address":[17171104,17168596,17171126,17168501],"length":1,"stats":{"Line":1}},{"line":150,"address":[13687033,13687629],"length":1,"stats":{"Line":2}},{"line":152,"address":[7027153],"length":1,"stats":{"Line":1}},{"line":153,"address":[13687445],"length":1,"stats":{"Line":1}},{"line":154,"address":[17169171],"length":1,"stats":{"Line":1}},{"line":158,"address":[7022869],"length":1,"stats":{"Line":10}},{"line":159,"address":[17164456,17164637],"length":1,"stats":{"Line":20}},{"line":160,"address":[7022845],"length":1,"stats":{"Line":10}},{"line":165,"address":[13696639,13693456,13693751,13693920,13694806,13693772,13693687,13694820,13693548,13693730,13693855],"length":1,"stats":{"Line":45}},{"line":166,"address":[13703747,13693878,13693985],"length":1,"stats":{"Line":9}},{"line":171,"address":[7033669,7033573],"length":1,"stats":{"Line":2}},{"line":175,"address":[6949476],"length":1,"stats":{"Line":3}},{"line":176,"address":[7036537,7036608,7043280,7043302],"length":1,"stats":{"Line":1}},{"line":179,"address":[13404411],"length":1,"stats":{"Line":4}},{"line":180,"address":[13697971,13704470,13697876,13704448],"length":1,"stats":{"Line":1}},{"line":182,"address":[17179870],"length":1,"stats":{"Line":1}},{"line":183,"address":[13698256],"length":1,"stats":{"Line":1}},{"line":184,"address":[13698320],"length":1,"stats":{"Line":1}},{"line":186,"address":[17180110,17180026],"length":1,"stats":{"Line":2}},{"line":187,"address":[13701281,13698520,13698605,13693759,13698450,13701178],"length":1,"stats":{"Line":6}},{"line":188,"address":[13698951,13698798],"length":1,"stats":{"Line":2}},{"line":189,"address":[17180816],"length":1,"stats":{"Line":1}},{"line":190,"address":[7038632,7039021],"length":1,"stats":{"Line":2}},{"line":192,"address":[17180730],"length":1,"stats":{"Line":0}},{"line":193,"address":[17180762,17180954],"length":1,"stats":{"Line":0}},{"line":196,"address":[17180552],"length":1,"stats":{"Line":0}},{"line":197,"address":[17180616,17181853,17181458],"length":1,"stats":{"Line":0}},{"line":204,"address":[7033364,7040916,7037856,7041029],"length":1,"stats":{"Line":3}},{"line":205,"address":[13701934],"length":1,"stats":{"Line":1}},{"line":206,"address":[17183632,17183731],"length":1,"stats":{"Line":2}},{"line":208,"address":[17183507],"length":1,"stats":{"Line":0}},{"line":209,"address":[17184460,17183571,17184065],"length":1,"stats":{"Line":0}},{"line":213,"address":[13702146],"length":1,"stats":{"Line":1}},{"line":214,"address":[7041520],"length":1,"stats":{"Line":1}},{"line":215,"address":[7041526],"length":1,"stats":{"Line":1}},{"line":216,"address":[13702110],"length":1,"stats":{"Line":1}},{"line":220,"address":[7033596,7034425,7034836],"length":1,"stats":{"Line":18}},{"line":221,"address":[17178176],"length":1,"stats":{"Line":6}},{"line":223,"address":[13696305,13695243],"length":1,"stats":{"Line":12}},{"line":224,"address":[7035973],"length":1,"stats":{"Line":6}},{"line":229,"address":[13689710,13689943,13690188,13690037,13690162,13690571,13689648,13689838,13692725,13692770,13692812,13692623],"length":1,"stats":{"Line":25}},{"line":230,"address":[13689995,13690063],"length":1,"stats":{"Line":6}},{"line":237,"address":[16885732],"length":1,"stats":{"Line":3}},{"line":238,"address":[17172606,17172701,17174566,17174544],"length":1,"stats":{"Line":1}},{"line":240,"address":[17172834],"length":1,"stats":{"Line":1}},{"line":242,"address":[17171802],"length":1,"stats":{"Line":5}}],"covered":76,"coverable":90},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-sandbox","src","lib.rs"],"content":"//! GitForce Sandbox\n//!\n//! Container-based isolation for job execution.\n\npub mod docker;\npub mod limits;\n\npub use docker::{DockerSandbox, Sandbox, SandboxInstance, StepResult};\npub use limits::SandboxLimits;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-sandbox","src","limits.rs"],"content":"//! Sandbox resource limits\n\nuse serde::{Deserialize, Serialize};\n\n/// Resource limits for sandbox execution\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SandboxLimits {\n /// CPU time limit in milliseconds\n pub cpu_ms: u64,\n /// Memory limit in megabytes\n pub memory_mb: u64,\n /// Disk limit in megabytes\n pub disk_mb: u64,\n /// Execution timeout in seconds\n pub timeout_secs: u64,\n /// Whether to allow network access\n pub network: bool,\n}\n\nimpl Default for SandboxLimits {\n fn default() -> Self {\n Self {\n cpu_ms: 3600000, // 1 hour\n memory_mb: 4096, // 4GB\n disk_mb: 10240, // 10GB\n timeout_secs: 3600, // 1 hour\n network: true,\n }\n }\n}\n\nimpl SandboxLimits {\n /// Create limits for a specific tier\n pub fn small() -> Self {\n Self {\n cpu_ms: 300000, // 5 minutes\n memory_mb: 512,\n disk_mb: 1024,\n timeout_secs: 300,\n network: false,\n }\n }\n\n pub fn medium() -> Self {\n Self {\n cpu_ms: 1800000, // 30 minutes\n memory_mb: 2048,\n disk_mb: 5120,\n timeout_secs: 1800,\n network: true,\n }\n }\n\n pub fn large() -> Self {\n Self {\n cpu_ms: 3600000,\n memory_mb: 8192,\n disk_mb: 20480,\n timeout_secs: 3600,\n network: true,\n }\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_sandbox_limits_default() {\n let limits = SandboxLimits::default();\n assert_eq!(limits.cpu_ms, 3600000);\n assert_eq!(limits.memory_mb, 4096);\n assert!(limits.network);\n }\n\n #[test]\n fn test_sandbox_limits_small() {\n let limits = SandboxLimits::small();\n assert_eq!(limits.cpu_ms, 300000);\n assert_eq!(limits.memory_mb, 512);\n assert!(!limits.network);\n }\n\n #[test]\n fn test_sandbox_limits_medium() {\n let limits = SandboxLimits::medium();\n assert_eq!(limits.cpu_ms, 1800000);\n assert_eq!(limits.memory_mb, 2048);\n assert!(limits.network);\n }\n\n #[test]\n fn test_sandbox_limits_large() {\n let limits = SandboxLimits::large();\n assert_eq!(limits.cpu_ms, 3600000);\n assert_eq!(limits.memory_mb, 8192);\n assert!(limits.network);\n }\n\n #[test]\n fn test_sandbox_limits_debug() {\n let limits = SandboxLimits::default();\n let debug_str = format!(\"{:?}\", limits);\n assert!(debug_str.contains(\"cpu_ms\"));\n assert!(debug_str.contains(\"memory_mb\"));\n }\n\n #[test]\n fn test_sandbox_limits_clone() {\n let limits = SandboxLimits::large();\n let cloned = limits.clone();\n assert_eq!(cloned.cpu_ms, limits.cpu_ms);\n assert_eq!(cloned.memory_mb, limits.memory_mb);\n }\n\n #[test]\n fn test_sandbox_limits_all_tiers() {\n let small = SandboxLimits::small();\n let medium = SandboxLimits::medium();\n let large = SandboxLimits::large();\n let default = SandboxLimits::default();\n\n // Verify tier ordering\n assert!(small.memory_mb < medium.memory_mb);\n assert!(medium.memory_mb < large.memory_mb);\n assert!(default.memory_mb <= large.memory_mb);\n\n // Verify network settings\n assert!(!small.network); // Small has no network\n assert!(medium.network);\n assert!(large.network);\n assert!(default.network);\n }\n}\n","traces":[{"line":21,"address":[17038992],"length":1,"stats":{"Line":14}},{"line":34,"address":[17038368],"length":1,"stats":{"Line":2}},{"line":44,"address":[17038416],"length":1,"stats":{"Line":1}},{"line":54,"address":[17038320],"length":1,"stats":{"Line":1}}],"covered":4,"coverable":4},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-scheduler","src","assigner.rs"],"content":"//! Job scheduler\n\nuse crate::policy::{SchedulingPolicy, SimplePolicy};\nuse crate::queue::{JobQueue, Priority, QueuedJob};\nuse gitforce_common::{JobId, PipelineRunId, RepoId, RunnerId};\nuse gitforce_db::models::Runner;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::{broadcast, RwLock};\n\n/// Scheduler command\n#[derive(Debug)]\npub enum SchedulerCommand {\n /// Enqueue a new job\n Enqueue {\n job_id: JobId,\n pipeline_run_id: PipelineRunId,\n repo_id: RepoId,\n priority: Priority,\n },\n /// Cancel a job\n Cancel { job_id: JobId },\n /// Register a runner\n RegisterRunner(Runner),\n /// Runner heartbeat\n Heartbeat { runner_id: RunnerId },\n /// Runner went offline\n RunnerOffline { runner_id: RunnerId },\n}\n\n/// Scheduler event\n#[derive(Debug, Clone)]\npub enum SchedulerEvent {\n /// Job assigned to runner\n JobAssigned {\n job_id: JobId,\n runner_id: RunnerId,\n },\n /// No runner available for job\n NoRunnerAvailable { job_id: JobId },\n}\n\n/// Scheduler state\n#[derive(Debug)]\npub struct SchedulerState {\n pub queue: JobQueue,\n pub runners: HashMap,\n pub job_assignments: HashMap,\n}\n\nimpl Default for SchedulerState {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl SchedulerState {\n pub fn new() -> Self {\n Self {\n queue: JobQueue::new(),\n runners: HashMap::new(),\n job_assignments: HashMap::new(),\n }\n }\n\n pub fn add_runner(&mut self, runner: Runner) {\n self.runners.insert(runner.id, runner);\n }\n\n pub fn remove_runner(&mut self, runner_id: RunnerId) {\n self.runners.remove(&runner_id);\n }\n\n pub fn get_runner(&self, runner_id: RunnerId) -> Option<&Runner> {\n self.runners.get(&runner_id)\n }\n\n pub fn list_online_runners(&self) -> Vec {\n self.runners\n .values()\n .filter(|r| r.status == \"online\")\n .cloned()\n .collect()\n }\n}\n\n/// Job scheduler\npub struct Scheduler {\n state: Arc>,\n policy: Arc,\n event_tx: broadcast::Sender,\n}\n\nimpl Scheduler {\n pub fn new() -> Self {\n let (event_tx, _) = broadcast::channel(100);\n Self {\n state: Arc::new(RwLock::new(SchedulerState::new())),\n policy: Arc::new(SimplePolicy::new()),\n event_tx,\n }\n }\n\n /// Set the scheduling policy\n pub fn with_policy(self, policy: P) -> Self {\n Self {\n policy: Arc::new(policy),\n state: self.state,\n event_tx: self.event_tx,\n }\n }\n\n /// Subscribe to scheduler events\n pub fn subscribe(&self) -> broadcast::Receiver {\n self.event_tx.subscribe()\n }\n\n /// Enqueue a job\n pub async fn enqueue(\n &self,\n job_id: JobId,\n pipeline_run_id: PipelineRunId,\n repo_id: RepoId,\n ) {\n let job = QueuedJob::new(job_id, pipeline_run_id, repo_id);\n let mut state = self.state.write().await;\n state.queue.enqueue(job);\n tracing::debug!(\"job {} enqueued\", job_id);\n }\n\n /// Enqueue a job with priority\n pub async fn enqueue_with_priority(\n &self,\n job_id: JobId,\n pipeline_run_id: PipelineRunId,\n repo_id: RepoId,\n priority: Priority,\n ) {\n let job = QueuedJob::new(job_id, pipeline_run_id, repo_id).with_priority(priority);\n let mut state = self.state.write().await;\n state.queue.enqueue(job);\n tracing::debug!(\"job {} enqueued with {:?} priority\", job_id, priority);\n }\n\n /// Cancel a job\n pub async fn cancel(&self, job_id: JobId) {\n let mut state = self.state.write().await;\n if let Some(_job) = state.queue.remove(job_id) {\n tracing::debug!(\"job {} cancelled\", job_id);\n }\n // Also remove assignment if exists\n state.job_assignments.remove(&job_id);\n }\n\n /// Register a runner\n pub async fn register_runner(&self, runner: Runner) {\n let mut state = self.state.write().await;\n let runner_id = runner.id;\n state.add_runner(runner);\n tracing::info!(\"runner {} registered\", runner_id);\n }\n\n /// Handle runner heartbeat\n pub async fn heartbeat(&self, runner_id: RunnerId) {\n let mut state = self.state.write().await;\n if let Some(runner) = state.runners.get_mut(&runner_id) {\n runner.last_heartbeat = Some(chrono::Utc::now());\n tracing::debug!(\"runner {} heartbeat\", runner_id);\n }\n }\n\n /// Handle runner going offline\n pub async fn runner_offline(&self, runner_id: RunnerId) {\n let mut state = self.state.write().await;\n if let Some(runner) = state.runners.get_mut(&runner_id) {\n runner.status = \"offline\".to_string();\n tracing::warn!(\"runner {} went offline\", runner_id);\n }\n }\n\n /// Try to assign jobs to available runners\n pub async fn process_queue(&self) {\n let mut state = self.state.write().await;\n let runners = state.list_online_runners();\n\n // Try to assign jobs while we have available runners\n let mut assigned = None;\n\n if let Some(job) = state.queue.peek() {\n let job_id = job.job_id;\n\n // Select runner using policy\n let runner_id = self.policy.select_runner(job_id, &runners).await;\n\n match runner_id {\n Some(r_id) => {\n // Dequeue and assign\n state.queue.dequeue();\n state.job_assignments.insert(job_id, r_id);\n tracing::info!(\"assigned job {} to runner {}\", job_id, r_id);\n assigned = Some((job_id, r_id));\n }\n None => {\n // No runner available\n tracing::debug!(\"no runner available for job {}\", job_id);\n }\n }\n }\n\n // Emit event if we assigned a job\n if let Some((job_id, runner_id)) = assigned {\n let event = SchedulerEvent::JobAssigned { job_id, runner_id };\n let _ = self.event_tx.send(event);\n }\n }\n\n /// Get queue length\n pub async fn queue_len(&self) -> usize {\n let state = self.state.read().await;\n state.queue.len()\n }\n\n /// Check if a job is assigned\n pub async fn is_assigned(&self, job_id: JobId) -> Option {\n let state = self.state.read().await;\n state.job_assignments.get(&job_id).copied()\n }\n}\n\nimpl Default for Scheduler {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use gitforce_db::models::RunnerType;\n\n fn make_runner(id: RunnerId, name: &str, status: &str, capacity: i32) -> Runner {\n Runner {\n id,\n name: name.to_string(),\n runner_type: RunnerType::Docker.as_str().to_string(),\n status: status.to_string(),\n capacity,\n last_heartbeat: None,\n created_at: chrono::Utc::now(),\n }\n }\n\n #[tokio::test]\n async fn test_enqueue_dequeue() {\n let scheduler = Scheduler::new();\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n let job_id = JobId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n\n // Register a runner\n let runner = make_runner(RunnerId::new(), \"test-runner\", \"online\", 2);\n scheduler.register_runner(runner.clone()).await;\n\n // Process queue\n scheduler.process_queue().await;\n }\n\n #[tokio::test]\n async fn test_cancel() {\n let scheduler = Scheduler::new();\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n let job_id = JobId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n\n scheduler.cancel(job_id).await;\n assert_eq!(scheduler.queue_len().await, 0);\n }\n\n #[tokio::test]\n async fn test_enqueue_with_priority() {\n let scheduler = Scheduler::new();\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n let job_id = JobId::new();\n\n scheduler.enqueue_with_priority(job_id, run_id, repo_id, Priority::High).await;\n assert_eq!(scheduler.queue_len().await, 1);\n }\n\n #[tokio::test]\n async fn test_register_runner() {\n let scheduler = Scheduler::new();\n let runner = make_runner(RunnerId::new(), \"test-runner\", \"online\", 2);\n let runner_id = runner.id;\n\n scheduler.register_runner(runner).await;\n // Verify runner is registered by checking process_queue doesn't emit NoRunnerAvailable\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n let job_id = JobId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n scheduler.process_queue().await;\n\n let assigned = scheduler.is_assigned(job_id).await;\n assert_eq!(assigned, Some(runner_id));\n }\n\n #[tokio::test]\n async fn test_heartbeat() {\n let scheduler = Scheduler::new();\n let runner_id = RunnerId::new();\n let runner = make_runner(runner_id, \"test-runner\", \"online\", 2);\n\n scheduler.register_runner(runner).await;\n scheduler.heartbeat(runner_id).await;\n // No panic means success\n }\n\n #[tokio::test]\n async fn test_runner_offline() {\n let scheduler = Scheduler::new();\n let runner_id = RunnerId::new();\n let runner = make_runner(runner_id, \"test-runner\", \"online\", 2);\n\n scheduler.register_runner(runner).await;\n scheduler.runner_offline(runner_id).await;\n // No panic means success\n }\n\n #[tokio::test]\n async fn test_cancel_nonexistent_job() {\n let scheduler = Scheduler::new();\n let job_id = JobId::new();\n\n // Cancel should not panic\n scheduler.cancel(job_id).await;\n }\n\n #[tokio::test]\n async fn test_scheduler_subscribe() {\n let scheduler = Scheduler::new();\n let mut rx = scheduler.subscribe();\n\n // Try to receive with a timeout - should get an error since no event sent\n let result = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;\n assert!(result.is_err() || result.unwrap().is_err()); // Timeout or channel closed\n }\n\n #[test]\n fn test_scheduler_command_debug() {\n let cmd = SchedulerCommand::Enqueue {\n job_id: JobId::new(),\n pipeline_run_id: PipelineRunId::new(),\n repo_id: RepoId::new(),\n priority: Priority::Normal,\n };\n assert!(format!(\"{:?}\", cmd).contains(\"Enqueue\"));\n }\n\n #[test]\n fn test_scheduler_event_debug() {\n let evt = SchedulerEvent::NoRunnerAvailable { job_id: JobId::new() };\n assert!(format!(\"{:?}\", evt).contains(\"NoRunnerAvailable\"));\n }\n\n #[test]\n fn test_scheduler_state_new() {\n let state = SchedulerState::new();\n assert!(state.queue.is_empty());\n assert!(state.runners.is_empty());\n assert!(state.job_assignments.is_empty());\n }\n\n #[tokio::test]\n async fn test_scheduler_state_add_remove_runner() {\n let mut state = SchedulerState::new();\n let runner = make_runner(RunnerId::new(), \"test-runner\", \"online\", 2);\n\n state.add_runner(runner.clone());\n assert_eq!(state.runners.len(), 1);\n\n state.remove_runner(runner.id);\n assert!(state.runners.is_empty());\n }\n\n #[tokio::test]\n async fn test_scheduler_state_get_runner() {\n let mut state = SchedulerState::new();\n let runner = make_runner(RunnerId::new(), \"test-runner\", \"online\", 2);\n\n state.add_runner(runner.clone());\n assert!(state.get_runner(runner.id).is_some());\n assert!(state.get_runner(RunnerId::new()).is_none());\n }\n\n #[tokio::test]\n async fn test_scheduler_state_list_online_runners() {\n let mut state = SchedulerState::new();\n state.add_runner(make_runner(RunnerId::new(), \"runner1\", \"online\", 2));\n state.add_runner(make_runner(RunnerId::new(), \"runner2\", \"offline\", 2));\n\n let online = state.list_online_runners();\n assert_eq!(online.len(), 1);\n assert_eq!(online[0].name, \"runner1\");\n }\n\n #[test]\n fn test_priority_ord() {\n assert!(Priority::High > Priority::Normal);\n assert!(Priority::Normal > Priority::Low);\n assert!(Priority::High > Priority::Low);\n }\n\n #[test]\n fn test_priority_default() {\n assert_eq!(Priority::default(), Priority::Normal);\n }\n\n #[tokio::test]\n async fn test_scheduler_with_multiple_jobs_and_runners() {\n let scheduler = Scheduler::new();\n\n // Add multiple runners\n let runner1 = make_runner(RunnerId::new(), \"runner1\", \"online\", 4);\n let runner2 = make_runner(RunnerId::new(), \"runner2\", \"online\", 2);\n scheduler.register_runner(runner1.clone()).await;\n scheduler.register_runner(runner2.clone()).await;\n\n // Add multiple jobs\n for i in 0..5 {\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n scheduler.enqueue(job_id, run_id, repo_id).await;\n }\n\n assert_eq!(scheduler.queue_len().await, 5);\n\n // Process queue should assign jobs\n scheduler.process_queue().await;\n }\n\n #[tokio::test]\n async fn test_scheduler_assign_job_multiple_times() {\n let scheduler = Scheduler::new();\n let runner = make_runner(RunnerId::new(), \"runner1\", \"online\", 4);\n scheduler.register_runner(runner.clone()).await;\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n scheduler.enqueue(job_id, run_id, repo_id).await;\n\n // Process queue twice - second time should be no-op since job is assigned\n scheduler.process_queue().await;\n scheduler.process_queue().await;\n\n // Job should be assigned\n let assigned = scheduler.is_assigned(job_id).await;\n assert!(assigned.is_some());\n }\n\n #[tokio::test]\n async fn test_scheduler_cancel_assigned_job() {\n let scheduler = Scheduler::new();\n let runner = make_runner(RunnerId::new(), \"runner1\", \"online\", 4);\n scheduler.register_runner(runner.clone()).await;\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n scheduler.enqueue(job_id, run_id, repo_id).await;\n\n // Assign job\n scheduler.process_queue().await;\n assert!(scheduler.is_assigned(job_id).await.is_some());\n\n // Cancel should remove assignment\n scheduler.cancel(job_id).await;\n assert!(scheduler.is_assigned(job_id).await.is_none());\n }\n\n #[tokio::test]\n async fn test_scheduler_queue_len_after_dequeue() {\n let scheduler = Scheduler::new();\n let runner = make_runner(RunnerId::new(), \"runner1\", \"online\", 4);\n scheduler.register_runner(runner.clone()).await;\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n\n // Process queue - job should be dequeued\n scheduler.process_queue().await;\n assert_eq!(scheduler.queue_len().await, 0);\n }\n\n #[test]\n fn test_scheduler_state_with_multiple_runners() {\n let mut state = SchedulerState::new();\n state.add_runner(make_runner(RunnerId::new(), \"runner1\", \"online\", 4));\n state.add_runner(make_runner(RunnerId::new(), \"runner2\", \"online\", 2));\n state.add_runner(make_runner(RunnerId::new(), \"runner3\", \"offline\", 1));\n\n assert_eq!(state.runners.len(), 3);\n let online = state.list_online_runners();\n assert_eq!(online.len(), 2);\n }\n\n #[test]\n fn test_scheduler_state_remove_nonexistent() {\n let mut state = SchedulerState::new();\n state.remove_runner(RunnerId::new());\n assert!(state.runners.is_empty());\n }\n\n #[test]\n fn test_scheduler_state_get_nonexistent() {\n let state = SchedulerState::new();\n assert!(state.get_runner(RunnerId::new()).is_none());\n }\n\n #[tokio::test]\n async fn test_scheduler_is_assigned_not_found() {\n let scheduler = Scheduler::new();\n let assigned = scheduler.is_assigned(JobId::new()).await;\n assert!(assigned.is_none());\n }\n\n #[tokio::test]\n async fn test_scheduler_enqueue_with_priority_high() {\n let scheduler = Scheduler::new();\n let repo_id = RepoId::new();\n let run_id = PipelineRunId::new();\n\n scheduler.enqueue_with_priority(JobId::new(), run_id, repo_id, Priority::High).await;\n assert_eq!(scheduler.queue_len().await, 1);\n }\n\n #[tokio::test]\n async fn test_scheduler_with_policy() {\n use crate::policy::PriorityPolicy;\n let scheduler = Scheduler::new().with_policy(PriorityPolicy::new());\n let runner = make_runner(RunnerId::new(), \"runner1\", \"online\", 4);\n scheduler.register_runner(runner.clone()).await;\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n scheduler.enqueue(job_id, run_id, repo_id).await;\n\n scheduler.process_queue().await;\n assert!(scheduler.is_assigned(job_id).await.is_some());\n }\n}\n","traces":[{"line":52,"address":[11767088],"length":1,"stats":{"Line":0}},{"line":53,"address":[7296808],"length":1,"stats":{"Line":0}},{"line":58,"address":[7294563,7294557,7294352],"length":1,"stats":{"Line":1}},{"line":60,"address":[7294373],"length":1,"stats":{"Line":1}},{"line":61,"address":[7294383],"length":1,"stats":{"Line":10}},{"line":62,"address":[7294435],"length":1,"stats":{"Line":15}},{"line":66,"address":[7294032],"length":1,"stats":{"Line":1}},{"line":67,"address":[11764239],"length":1,"stats":{"Line":2}},{"line":70,"address":[7294176],"length":1,"stats":{"Line":2}},{"line":71,"address":[7294197],"length":1,"stats":{"Line":1}},{"line":74,"address":[11764352],"length":1,"stats":{"Line":1}},{"line":75,"address":[7294165],"length":1,"stats":{"Line":1}},{"line":78,"address":[11764448],"length":1,"stats":{"Line":1}},{"line":79,"address":[7294265],"length":1,"stats":{"Line":1}},{"line":81,"address":[7252958,7252944],"length":1,"stats":{"Line":3}},{"line":95,"address":[11765454,11765136,11765448],"length":1,"stats":{"Line":12}},{"line":96,"address":[11765153],"length":1,"stats":{"Line":7}},{"line":98,"address":[11765246],"length":1,"stats":{"Line":12}},{"line":99,"address":[7295104,7295063],"length":1,"stats":{"Line":15}},{"line":105,"address":[7253744,7253863],"length":1,"stats":{"Line":1}},{"line":107,"address":[7253762],"length":1,"stats":{"Line":1}},{"line":108,"address":[7253815],"length":1,"stats":{"Line":1}},{"line":109,"address":[],"length":0,"stats":{"Line":1}},{"line":114,"address":[11765648],"length":1,"stats":{"Line":1}},{"line":115,"address":[7295381],"length":1,"stats":{"Line":1}},{"line":119,"address":[11765520],"length":1,"stats":{"Line":2}},{"line":125,"address":[11748891],"length":1,"stats":{"Line":2}},{"line":126,"address":[7204839],"length":1,"stats":{"Line":2}},{"line":127,"address":[7271103,7271032],"length":1,"stats":{"Line":4}},{"line":128,"address":[11749642,11750072],"length":1,"stats":{"Line":13}},{"line":132,"address":[11765008],"length":1,"stats":{"Line":2}},{"line":139,"address":[7265282,7265095],"length":1,"stats":{"Line":4}},{"line":140,"address":[7185415],"length":1,"stats":{"Line":2}},{"line":141,"address":[11744095,11744008],"length":1,"stats":{"Line":4}},{"line":142,"address":[11744591,11744161],"length":1,"stats":{"Line":4}},{"line":146,"address":[11746111,11746257,11748371,11746080,11746414,11746206],"length":1,"stats":{"Line":4}},{"line":147,"address":[11746187,11746307,11746236,11746448],"length":1,"stats":{"Line":2}},{"line":148,"address":[11746681,11746768],"length":1,"stats":{"Line":2}},{"line":149,"address":[7268487,7268548],"length":1,"stats":{"Line":2}},{"line":152,"address":[11746926,11748285],"length":1,"stats":{"Line":2}},{"line":156,"address":[11740848,11740447,11742781,11742802,11740636,11740416],"length":1,"stats":{"Line":26}},{"line":157,"address":[7262512,7262639,7262393,7262438],"length":1,"stats":{"Line":8}},{"line":158,"address":[11741129],"length":1,"stats":{"Line":7}},{"line":159,"address":[7262897,7262968],"length":1,"stats":{"Line":9}},{"line":160,"address":[7263110,7263536],"length":1,"stats":{"Line":14}},{"line":164,"address":[7273050,7273098,7272928,7273243,7275183,7272959],"length":1,"stats":{"Line":4}},{"line":165,"address":[7273035,7273277,7273156,7273080],"length":1,"stats":{"Line":2}},{"line":166,"address":[11752273,11752739,11752191,11752025,11752112],"length":1,"stats":{"Line":5}},{"line":167,"address":[7273711,7273743],"length":1,"stats":{"Line":2}},{"line":168,"address":[7274208,7273795],"length":1,"stats":{"Line":2}},{"line":173,"address":[7259642,7259594,7259472,7259787,7259503,7261805],"length":1,"stats":{"Line":4}},{"line":174,"address":[11737859,11738000,11737788,11737739],"length":1,"stats":{"Line":2}},{"line":175,"address":[7260050,7260818,7260121,7260286,7260192],"length":1,"stats":{"Line":4}},{"line":176,"address":[11738506,11738529,11738462],"length":1,"stats":{"Line":2}},{"line":177,"address":[7260417,7260830],"length":1,"stats":{"Line":2}},{"line":182,"address":[11764864,11764872],"length":1,"stats":{"Line":12}},{"line":183,"address":[7254247,7254029,7254126,7253980],"length":1,"stats":{"Line":6}},{"line":184,"address":[7254465,7254544],"length":1,"stats":{"Line":6}},{"line":187,"address":[7254563],"length":1,"stats":{"Line":3}},{"line":189,"address":[7254567,7254639],"length":1,"stats":{"Line":9}},{"line":190,"address":[7254725],"length":1,"stats":{"Line":6}},{"line":193,"address":[11732907,11732070,11733140,11732799],"length":1,"stats":{"Line":9}},{"line":195,"address":[7255237],"length":1,"stats":{"Line":1}},{"line":196,"address":[11733360],"length":1,"stats":{"Line":6}},{"line":198,"address":[7256746,7255281],"length":1,"stats":{"Line":13}},{"line":199,"address":[7256761],"length":1,"stats":{"Line":7}},{"line":200,"address":[11734982,11735588],"length":1,"stats":{"Line":12}},{"line":201,"address":[7257271],"length":1,"stats":{"Line":2}},{"line":205,"address":[7255303],"length":1,"stats":{"Line":0}},{"line":211,"address":[7258509,7258652,7254771,7258650],"length":1,"stats":{"Line":9}},{"line":212,"address":[11736676],"length":1,"stats":{"Line":2}},{"line":213,"address":[7258573],"length":1,"stats":{"Line":5}},{"line":218,"address":[7275593,7275700,7275568,7276198,7275661,7275824],"length":1,"stats":{"Line":20}},{"line":219,"address":[7275649,7275855,7275752,7275688],"length":1,"stats":{"Line":10}},{"line":220,"address":[7276128,7276074],"length":1,"stats":{"Line":12}},{"line":224,"address":[7294576,7294584],"length":1,"stats":{"Line":4}},{"line":225,"address":[7183079],"length":1,"stats":{"Line":2}},{"line":226,"address":[7253646,7253519,7253578],"length":1,"stats":{"Line":3}},{"line":231,"address":[7295840],"length":1,"stats":{"Line":0}},{"line":232,"address":[7295848],"length":1,"stats":{"Line":0}}],"covered":75,"coverable":80},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-scheduler","src","lib.rs"],"content":"//! GitForce Scheduler\n//!\n//! Job queue management, runner assignment, and scheduling policies.\n\npub mod queue;\npub mod policy;\npub mod assigner;\npub mod server;\n\npub use queue::{JobQueue, Priority};\npub use policy::{SchedulingPolicy, SimplePolicy};\npub use assigner::Scheduler;\npub use server::{SchedulerServerState, create_state, scheduler_routes};\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-scheduler","src","policy.rs"],"content":"//! Scheduling policies\n\nuse async_trait::async_trait;\nuse gitforce_common::{JobId, RunnerId};\nuse gitforce_db::models::Runner;\n\n/// Scheduling policy trait\n#[async_trait]\npub trait SchedulingPolicy: Send + Sync {\n /// Select the best runner for a job\n async fn select_runner(\n &self,\n job_id: JobId,\n runners: &[Runner],\n ) -> Option;\n}\n\n/// Simple round-robin / least-loaded policy\npub struct SimplePolicy;\n\nimpl SimplePolicy {\n pub fn new() -> Self {\n Self\n }\n}\n\nimpl Default for SimplePolicy {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl SchedulingPolicy for SimplePolicy {\n async fn select_runner(\n &self,\n _job_id: JobId,\n runners: &[Runner],\n ) -> Option {\n // Filter to only online runners with capacity\n let available: Vec<_> = runners\n .iter()\n .filter(|r| r.status == \"online\" && r.capacity > 0)\n .collect();\n\n if available.is_empty() {\n return None;\n }\n\n // Select the runner with most available capacity\n // In a real implementation, we'd track actual load\n let best = available\n .iter()\n .max_by_key(|r| r.capacity)?;\n\n Some(best.id)\n }\n}\n\n/// Priority-based policy\npub struct PriorityPolicy;\n\nimpl PriorityPolicy {\n pub fn new() -> Self {\n Self\n }\n}\n\nimpl Default for PriorityPolicy {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl SchedulingPolicy for PriorityPolicy {\n async fn select_runner(\n &self,\n job_id: JobId,\n runners: &[Runner],\n ) -> Option {\n // Use simple policy for now\n SimplePolicy::new().select_runner(job_id, runners).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use gitforce_common::RunnerId;\n use gitforce_db::models::Runner;\n\n fn make_runner(id: RunnerId, name: &str, status: &str, capacity: i32) -> Runner {\n Runner {\n id,\n name: name.to_string(),\n runner_type: \"docker\".to_string(),\n status: status.to_string(),\n capacity,\n last_heartbeat: None,\n created_at: chrono::Utc::now(),\n }\n }\n\n #[tokio::test]\n async fn test_simple_policy_selects_available_runner() {\n let policy = SimplePolicy::new();\n let runners = vec![\n make_runner(RunnerId::new(), \"runner-1\", \"online\", 4),\n make_runner(RunnerId::new(), \"runner-2\", \"online\", 2),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_some());\n }\n\n #[tokio::test]\n async fn test_simple_policy_skips_offline_runners() {\n let policy = SimplePolicy::new();\n let runners = vec![\n make_runner(RunnerId::new(), \"runner-1\", \"offline\", 4),\n make_runner(RunnerId::new(), \"runner-2\", \"online\", 2),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_some());\n }\n\n #[tokio::test]\n async fn test_simple_policy_skips_zero_capacity() {\n let policy = SimplePolicy::new();\n let runner1 = RunnerId::new();\n let runner2 = RunnerId::new();\n let runners = vec![\n make_runner(runner1, \"runner-1\", \"online\", 0),\n make_runner(runner2, \"runner-2\", \"online\", 2),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert_eq!(selected, Some(runner2));\n }\n\n #[tokio::test]\n async fn test_simple_policy_selects_most_capacity() {\n let policy = SimplePolicy::new();\n let runner1 = RunnerId::new();\n let runner2 = RunnerId::new();\n let runners = vec![\n make_runner(runner1, \"runner-1\", \"online\", 2),\n make_runner(runner2, \"runner-2\", \"online\", 8),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert_eq!(selected, Some(runner2));\n }\n\n #[tokio::test]\n async fn test_simple_policy_returns_none_when_no_runners() {\n let policy = SimplePolicy::new();\n let runners: Vec = vec![];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_none());\n }\n\n #[tokio::test]\n async fn test_simple_policy_returns_none_when_all_offline() {\n let policy = SimplePolicy::new();\n let runners = vec![\n make_runner(RunnerId::new(), \"runner-1\", \"offline\", 4),\n make_runner(RunnerId::new(), \"runner-2\", \"offline\", 2),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_none());\n }\n\n #[tokio::test]\n async fn test_priority_policy_uses_simple_policy() {\n let policy = PriorityPolicy::new();\n let runners = vec![\n make_runner(RunnerId::new(), \"runner-1\", \"online\", 4),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_some());\n }\n\n #[test]\n fn test_simple_policy_new() {\n let policy = SimplePolicy::new();\n assert!(matches!(policy, SimplePolicy));\n }\n\n #[test]\n fn test_priority_policy_new() {\n let policy = PriorityPolicy::new();\n assert!(matches!(policy, PriorityPolicy));\n }\n\n #[tokio::test]\n async fn test_simple_policy_with_single_runner() {\n let policy = SimplePolicy::new();\n let runner_id = RunnerId::new();\n let runners = vec![\n make_runner(runner_id, \"solo-runner\", \"online\", 1),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert_eq!(selected, Some(runner_id));\n }\n\n #[tokio::test]\n async fn test_simple_policy_all_zero_capacity() {\n let policy = SimplePolicy::new();\n let runners = vec![\n make_runner(RunnerId::new(), \"runner-1\", \"online\", 0),\n make_runner(RunnerId::new(), \"runner-2\", \"online\", 0),\n ];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_none());\n }\n\n #[tokio::test]\n async fn test_priority_policy_no_runners() {\n let policy = PriorityPolicy::new();\n let runners: Vec = vec![];\n let job_id = JobId::new();\n\n let selected = policy.select_runner(job_id, &runners).await;\n assert!(selected.is_none());\n }\n\n #[test]\n fn test_priority_policy_default() {\n let policy: PriorityPolicy = Default::default();\n assert!(matches!(policy, PriorityPolicy));\n }\n\n #[test]\n fn test_simple_policy_default() {\n let policy: SimplePolicy = Default::default();\n assert!(matches!(policy, SimplePolicy));\n }\n}\n","traces":[{"line":28,"address":[7558832],"length":1,"stats":{"Line":1}},{"line":29,"address":[7558833],"length":1,"stats":{"Line":1}},{"line":35,"address":[7143792,7144570,7143898,7144131,7144595,7144079,7143830],"length":1,"stats":{"Line":9}},{"line":41,"address":[7144039],"length":1,"stats":{"Line":2}},{"line":43,"address":[7144659,7144640,7144180],"length":1,"stats":{"Line":4}},{"line":46,"address":[7144285,7144230],"length":1,"stats":{"Line":2}},{"line":47,"address":[7144316],"length":1,"stats":{"Line":1}},{"line":52,"address":[7144383,7144299,7144575],"length":1,"stats":{"Line":3}},{"line":54,"address":[7144736,7144366,7144746],"length":1,"stats":{"Line":4}},{"line":56,"address":[7144470],"length":1,"stats":{"Line":3}},{"line":70,"address":[7558848],"length":1,"stats":{"Line":1}},{"line":71,"address":[7558849],"length":1,"stats":{"Line":1}},{"line":77,"address":[7144768,7145251,7145497,7144976,7145492,7145080,7144874,7144803,7145028],"length":1,"stats":{"Line":6}},{"line":83,"address":[7144901,7145006,7145124,7145282],"length":1,"stats":{"Line":4}}],"covered":14,"coverable":14},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-scheduler","src","queue.rs"],"content":"//! Job queue implementation\n\nuse gitforce_common::{JobId, PipelineRunId, RepoId};\nuse std::collections::{BinaryHeap, HashMap};\nuse std::cmp::Ordering;\n\n/// Job priority\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum Priority {\n #[default]\n Normal = 1,\n High = 2,\n Low = 0,\n}\n\nimpl Ord for Priority {\n fn cmp(&self, other: &Self) -> Ordering {\n (*self as i32).cmp(&(*other as i32))\n }\n}\n\nimpl PartialOrd for Priority {\n fn partial_cmp(&self, other: &Self) -> Option {\n Some(self.cmp(other))\n }\n}\n\n/// Queued job entry\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct QueuedJob {\n pub job_id: JobId,\n pub pipeline_run_id: PipelineRunId,\n pub repo_id: RepoId,\n pub priority: Priority,\n pub queued_at: i64,\n}\n\nimpl QueuedJob {\n pub fn new(job_id: JobId, pipeline_run_id: PipelineRunId, repo_id: RepoId) -> Self {\n Self {\n job_id,\n pipeline_run_id,\n repo_id,\n priority: Priority::default(),\n queued_at: chrono::Utc::now().timestamp_millis(),\n }\n }\n\n pub fn with_priority(mut self, priority: Priority) -> Self {\n self.priority = priority;\n self\n }\n}\n\n/// FIFO ordering for same-priority jobs (older first)\nimpl PartialOrd for QueuedJob {\n fn partial_cmp(&self, other: &Self) -> Option {\n Some(self.cmp(other))\n }\n}\n\nimpl Ord for QueuedJob {\n fn cmp(&self, other: &Self) -> Ordering {\n // Higher priority first (BinaryHeap extracts max, so larger priority first)\n let priority_cmp = self.priority.cmp(&other.priority);\n if priority_cmp != Ordering::Equal {\n return priority_cmp;\n }\n\n // Older first (FIFO within same priority) - reverse so older is \"larger\" for heap\n other.queued_at.cmp(&self.queued_at)\n }\n}\n\n/// Job queue with priority support\n#[derive(Debug)]\npub struct JobQueue {\n heap: BinaryHeap,\n by_id: HashMap,\n}\n\nimpl Default for JobQueue {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl JobQueue {\n /// Create a new job queue\n pub fn new() -> Self {\n Self {\n heap: BinaryHeap::new(),\n by_id: HashMap::new(),\n }\n }\n\n /// Enqueue a job\n pub fn enqueue(&mut self, job: QueuedJob) {\n let job_id = job.job_id;\n self.by_id.insert(job_id, job.clone());\n self.heap.push(job);\n }\n\n /// Dequeue the highest priority job\n pub fn dequeue(&mut self) -> Option {\n let job = self.heap.pop()?;\n self.by_id.remove(&job.job_id);\n Some(job)\n }\n\n /// Peek at the next job without removing it\n pub fn peek(&self) -> Option<&QueuedJob> {\n self.heap.peek()\n }\n\n /// Remove a specific job from the queue\n pub fn remove(&mut self, job_id: JobId) -> Option {\n let job = self.by_id.remove(&job_id)?;\n // Note: We can't efficiently remove from BinaryHeap, so we mark it as removed\n // by checking by_id when dequeueing\n Some(job)\n }\n\n /// Check if the queue contains a job\n pub fn contains(&self, job_id: JobId) -> bool {\n self.by_id.contains_key(&job_id)\n }\n\n /// Get queue length\n pub fn len(&self) -> usize {\n self.by_id.len()\n }\n\n /// Check if empty\n pub fn is_empty(&self) -> bool {\n self.by_id.is_empty()\n }\n\n /// Get all queued jobs\n pub fn all(&self) -> Vec<&QueuedJob> {\n self.by_id.values().collect()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_queue_ordering() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job1 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id);\n let job2 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id)\n .with_priority(Priority::High);\n let job3 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id)\n .with_priority(Priority::Low);\n\n queue.enqueue(job1.clone());\n queue.enqueue(job2.clone());\n queue.enqueue(job3.clone());\n\n // High priority should come first\n let first = queue.dequeue().unwrap();\n assert_eq!(first.job_id, job2.job_id);\n }\n\n #[test]\n fn test_fifo_within_priority() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job1 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id);\n std::thread::sleep(std::time::Duration::from_millis(1));\n let job2 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id);\n\n queue.enqueue(job1.clone());\n queue.enqueue(job2.clone());\n\n // First enqueued should come first (FIFO)\n let first = queue.dequeue().unwrap();\n assert_eq!(first.job_id, job1.job_id);\n }\n\n #[test]\n fn test_queue_peek() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n assert!(queue.peek().is_none());\n\n let job = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id)\n .with_priority(Priority::High);\n queue.enqueue(job.clone());\n\n let peeked = queue.peek().unwrap();\n assert_eq!(peeked.job_id, job.job_id);\n }\n\n #[test]\n fn test_queue_contains() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job_id = JobId::new();\n let job = QueuedJob::new(job_id, PipelineRunId::new(), repo_id);\n queue.enqueue(job);\n\n assert!(queue.contains(job_id));\n assert!(!queue.contains(JobId::new()));\n }\n\n #[test]\n fn test_queue_remove() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job_id = JobId::new();\n let job = QueuedJob::new(job_id, PipelineRunId::new(), repo_id);\n queue.enqueue(job.clone());\n\n let removed = queue.remove(job_id);\n assert!(removed.is_some());\n assert!(!queue.contains(job_id));\n }\n\n #[test]\n fn test_queue_remove_nonexistent() {\n let mut queue = JobQueue::new();\n let removed = queue.remove(JobId::new());\n assert!(removed.is_none());\n }\n\n #[test]\n fn test_queue_len() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n assert_eq!(queue.len(), 0);\n\n queue.enqueue(QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id));\n assert_eq!(queue.len(), 1);\n\n queue.enqueue(QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id));\n assert_eq!(queue.len(), 2);\n\n queue.dequeue();\n assert_eq!(queue.len(), 1);\n }\n\n #[test]\n fn test_queue_is_empty() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n assert!(queue.is_empty());\n\n queue.enqueue(QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id));\n assert!(!queue.is_empty());\n\n queue.dequeue();\n assert!(queue.is_empty());\n }\n\n #[test]\n fn test_queue_all() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job1 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id);\n let job2 = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id);\n queue.enqueue(job1.clone());\n queue.enqueue(job2.clone());\n\n let all = queue.all();\n assert_eq!(all.len(), 2);\n }\n\n #[test]\n fn test_priority_ordering() {\n assert!(Priority::High > Priority::Normal);\n assert!(Priority::High > Priority::Low);\n assert!(Priority::Normal > Priority::Low);\n assert!(Priority::Low < Priority::High);\n }\n\n #[test]\n fn test_queued_job_with_priority() {\n let repo_id = RepoId::new();\n let job = QueuedJob::new(JobId::new(), PipelineRunId::new(), repo_id)\n .with_priority(Priority::High);\n assert_eq!(job.priority, Priority::High);\n }\n\n #[test]\n fn test_priority_debug() {\n let priority = Priority::High;\n let debug_str = format!(\"{:?}\", priority);\n assert!(debug_str.contains(\"High\"));\n }\n\n #[test]\n fn test_priority_clone() {\n let priority = Priority::Normal;\n let cloned = priority;\n assert_eq!(priority, cloned);\n }\n\n #[test]\n fn test_priority_eq() {\n assert_eq!(Priority::High, Priority::High);\n assert_eq!(Priority::Normal, Priority::Normal);\n assert_eq!(Priority::Low, Priority::Low);\n assert_ne!(Priority::High, Priority::Low);\n }\n\n #[test]\n fn test_priority_ord_consistency() {\n // Verify ordering is consistent with comparison\n assert!(Priority::High >= Priority::High);\n assert!(Priority::High > Priority::Normal);\n assert!(Priority::Normal >= Priority::Normal);\n assert!(Priority::Normal > Priority::Low);\n assert!(Priority::Low >= Priority::Low);\n }\n\n #[test]\n fn test_queue_remove_after_dequeue() {\n let mut queue = JobQueue::new();\n let repo_id = RepoId::new();\n\n let job_id = JobId::new();\n let job = QueuedJob::new(job_id, PipelineRunId::new(), repo_id);\n queue.enqueue(job);\n\n // Dequeue first\n queue.dequeue();\n\n // Now try to remove - should return None since already dequeued\n let removed = queue.remove(job_id);\n assert!(removed.is_none());\n }\n\n #[test]\n fn test_queued_job_all_fields() {\n let repo_id = RepoId::new();\n let job_id = JobId::new();\n let pipeline_run_id = PipelineRunId::new();\n\n let job = QueuedJob {\n job_id,\n pipeline_run_id,\n repo_id,\n priority: Priority::High,\n queued_at: 1234567890,\n };\n\n assert_eq!(job.job_id, job_id);\n assert_eq!(job.pipeline_run_id, pipeline_run_id);\n assert_eq!(job.repo_id, repo_id);\n assert_eq!(job.priority, Priority::High);\n assert_eq!(job.queued_at, 1234567890);\n }\n}\n","traces":[{"line":17,"address":[11783184],"length":1,"stats":{"Line":1}},{"line":18,"address":[7557262],"length":1,"stats":{"Line":1}},{"line":23,"address":[7558368],"length":1,"stats":{"Line":1}},{"line":24,"address":[7558382],"length":1,"stats":{"Line":1}},{"line":39,"address":[7557072],"length":1,"stats":{"Line":4}},{"line":44,"address":[7557101],"length":1,"stats":{"Line":4}},{"line":45,"address":[11783046],"length":1,"stats":{"Line":4}},{"line":49,"address":[7557024],"length":1,"stats":{"Line":2}},{"line":50,"address":[7557042],"length":1,"stats":{"Line":2}},{"line":51,"address":[7557045],"length":1,"stats":{"Line":2}},{"line":57,"address":[7558608],"length":1,"stats":{"Line":1}},{"line":58,"address":[7558622],"length":1,"stats":{"Line":1}},{"line":63,"address":[7557312],"length":1,"stats":{"Line":1}},{"line":65,"address":[7557335],"length":1,"stats":{"Line":1}},{"line":66,"address":[7557352],"length":1,"stats":{"Line":1}},{"line":67,"address":[11783339],"length":1,"stats":{"Line":1}},{"line":71,"address":[7557383],"length":1,"stats":{"Line":1}},{"line":83,"address":[7558560],"length":1,"stats":{"Line":0}},{"line":84,"address":[7558568],"length":1,"stats":{"Line":0}},{"line":90,"address":[7556378,7556256,7556384],"length":1,"stats":{"Line":4}},{"line":92,"address":[7556274],"length":1,"stats":{"Line":14}},{"line":93,"address":[7556284],"length":1,"stats":{"Line":14}},{"line":98,"address":[7556784],"length":1,"stats":{"Line":2}},{"line":99,"address":[11782745],"length":1,"stats":{"Line":2}},{"line":100,"address":[7556826],"length":1,"stats":{"Line":2}},{"line":101,"address":[7556904],"length":1,"stats":{"Line":2}},{"line":105,"address":[7556576],"length":1,"stats":{"Line":1}},{"line":106,"address":[7556605],"length":1,"stats":{"Line":1}},{"line":107,"address":[11782664],"length":1,"stats":{"Line":3}},{"line":108,"address":[7556752],"length":1,"stats":{"Line":3}},{"line":112,"address":[11782336],"length":1,"stats":{"Line":2}},{"line":113,"address":[7556405],"length":1,"stats":{"Line":5}},{"line":117,"address":[7556416],"length":1,"stats":{"Line":1}},{"line":118,"address":[7556441],"length":1,"stats":{"Line":1}},{"line":121,"address":[11782473],"length":1,"stats":{"Line":1}},{"line":125,"address":[7556960],"length":1,"stats":{"Line":1}},{"line":126,"address":[11782901],"length":1,"stats":{"Line":1}},{"line":130,"address":[7556240],"length":1,"stats":{"Line":6}},{"line":131,"address":[7556245],"length":1,"stats":{"Line":6}},{"line":135,"address":[7556992],"length":1,"stats":{"Line":1}},{"line":136,"address":[7556997],"length":1,"stats":{"Line":1}},{"line":140,"address":[7556176],"length":1,"stats":{"Line":1}},{"line":141,"address":[7556195],"length":1,"stats":{"Line":1}}],"covered":41,"coverable":43},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-scheduler","src","server.rs"],"content":"//! Scheduler HTTP server\n\nuse crate::Scheduler;\nuse axum::{\n extract::{Path, State},\n http::StatusCode,\n response::IntoResponse,\n routing::{get, post},\n Json, Router,\n};\nuse gitforce_common::{JobId, RunnerId};\nuse gitforce_db::models::{Runner, RunnerType};\nuse serde::{Deserialize, Serialize};\nuse std::sync::Arc;\nuse uuid::Uuid;\n\n/// Application state for scheduler server\n#[derive(Clone)]\npub struct SchedulerServerState {\n pub scheduler: Arc,\n}\n\n/// Runner registration request\n#[derive(Debug, Deserialize)]\npub struct RegisterRunnerRequest {\n pub name: String,\n #[serde(rename = \"type\")]\n pub runner_type: String,\n pub capacity: i32,\n}\n\n/// Job info for pending jobs response\n#[derive(Debug, Serialize)]\npub struct PendingJobInfo {\n pub job_id: String,\n pub name: String,\n pub pipeline_run_id: String,\n pub commands: Vec,\n pub working_dir: Option,\n}\n\n/// Create scheduler server state\npub fn create_state(scheduler: Scheduler) -> SchedulerServerState {\n SchedulerServerState {\n scheduler: Arc::new(scheduler),\n }\n}\n\n/// Create scheduler routes\npub fn scheduler_routes(\n state: SchedulerServerState,\n) -> Router {\n Router::new()\n .route(\"/runners\", post(register_runner))\n .route(\"/runners/{id}/heartbeat\", post(runner_heartbeat))\n .route(\"/jobs/pending\", get(get_pending_jobs))\n .route(\"/jobs/{id}/assign\", post(assign_job))\n .route(\"/jobs/{id}/complete\", post(complete_job))\n .with_state(state)\n}\n\n/// Register a new runner\nasync fn register_runner(\n State(state): State,\n Json(request): Json,\n) -> impl IntoResponse {\n let runner_type = match request.runner_type.as_str() {\n \"docker\" => RunnerType::Docker,\n \"firecracker\" => RunnerType::Firecracker,\n \"bare-metal\" | \"bare_metal\" => RunnerType::BareMetal,\n _ => RunnerType::Docker,\n };\n\n let runner = Runner::new(request.name, runner_type, request.capacity);\n\n state.scheduler.register_runner(runner.clone()).await;\n\n tracing::info!(\"runner {} registered via HTTP\", runner.id);\n\n (StatusCode::CREATED, Json(serde_json::json!({\n \"id\": runner.id.to_string(),\n \"name\": runner.name,\n \"type\": runner.runner_type,\n \"status\": runner.status,\n \"capacity\": runner.capacity,\n })))\n}\n\n/// Runner heartbeat\nasync fn runner_heartbeat(\n State(state): State,\n Path(runner_id): Path,\n) -> impl IntoResponse {\n let runner_id: RunnerId = match Uuid::parse_str(&runner_id) {\n Ok(id) => RunnerId::from(id),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_runner_id\",\n \"message\": \"Invalid runner ID format\"\n })))\n }\n };\n\n state.scheduler.heartbeat(runner_id).await;\n\n (StatusCode::OK, Json(serde_json::json!({\n \"status\": \"ok\"\n })))\n}\n\n/// Get pending jobs for a runner\nasync fn get_pending_jobs(\n State(state): State,\n) -> impl IntoResponse {\n // Process queue to assign pending jobs\n state.scheduler.process_queue().await;\n\n // Return empty array - actual job info would come from database\n // In real implementation, this would query job details from DB\n Json(serde_json::json!([]))\n}\n\n/// Assign a job to a runner (runner claims a job)\nasync fn assign_job(\n State(_state): State,\n Path(job_id): Path,\n Json(request): Json,\n) -> impl IntoResponse {\n let job_id: JobId = match Uuid::parse_str(&job_id) {\n Ok(id) => JobId::from(id),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_job_id\",\n \"message\": \"Invalid job ID format\"\n })))\n }\n };\n\n let runner_id: Option = request[\"runner_id\"]\n .as_str()\n .and_then(|s| Uuid::parse_str(s).ok().map(RunnerId::from));\n\n if let Some(r_id) = runner_id {\n // In real impl, mark job as assigned to this runner\n tracing::info!(\"job {} assigned to runner {} via HTTP\", job_id, r_id);\n }\n\n (StatusCode::OK, Json(serde_json::json!({\n \"status\": \"assigned\",\n \"job_id\": job_id.to_string()\n })))\n}\n\n/// Complete a job\nasync fn complete_job(\n State(_state): State,\n Path(job_id): Path,\n Json(request): Json,\n) -> impl IntoResponse {\n let job_id: JobId = match Uuid::parse_str(&job_id) {\n Ok(id) => JobId::from(id),\n Err(_) => {\n return (StatusCode::BAD_REQUEST, Json(serde_json::json!({\n \"error\": \"invalid_job_id\",\n \"message\": \"Invalid job ID format\"\n })))\n }\n };\n\n let success = request[\"success\"].as_bool().unwrap_or(false);\n tracing::info!(\"job {} completed via HTTP: success={}\", job_id, success);\n\n (StatusCode::OK, Json(serde_json::json!({\n \"status\": \"completed\",\n \"job_id\": job_id.to_string()\n })))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use axum::http::StatusCode;\n\n fn assert_status(response: axum::response::Response, expected: StatusCode) {\n let status = response.status();\n assert_eq!(status, expected, \"Expected status {:?}, got {:?}\", expected, status);\n }\n\n #[tokio::test]\n async fn test_register_runner_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let request = RegisterRunnerRequest {\n name: \"test-runner\".to_string(),\n runner_type: \"docker\".to_string(),\n capacity: 4,\n };\n\n let response = register_runner(\n axum::extract::State(state),\n axum::Json(request),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::CREATED);\n }\n\n #[tokio::test]\n async fn test_register_runner_bare_metal_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let request = RegisterRunnerRequest {\n name: \"bare-runner\".to_string(),\n runner_type: \"bare-metal\".to_string(),\n capacity: 8,\n };\n\n let response = register_runner(\n axum::extract::State(state),\n axum::Json(request),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::CREATED);\n }\n\n #[tokio::test]\n async fn test_runner_heartbeat_valid_uuid() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let runner_id = uuid::Uuid::new_v4();\n\n let response = runner_heartbeat(\n axum::extract::State(state),\n axum::extract::Path(runner_id.to_string()),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_runner_heartbeat_invalid_uuid() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let response = runner_heartbeat(\n axum::extract::State(state),\n axum::extract::Path(\"not-a-uuid\".to_string()),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::BAD_REQUEST);\n }\n\n #[tokio::test]\n async fn test_get_pending_jobs_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let response = get_pending_jobs(axum::extract::State(state)).await;\n\n let resp = response.into_response();\n assert_status(resp, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_assign_job_valid_uuid() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let job_id = uuid::Uuid::new_v4();\n let runner_id = uuid::Uuid::new_v4();\n\n let response = assign_job(\n axum::extract::State(state),\n axum::extract::Path(job_id.to_string()),\n axum::Json(serde_json::json!({\n \"runner_id\": runner_id.to_string()\n })),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_assign_job_invalid_uuid() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let response = assign_job(\n axum::extract::State(state),\n axum::extract::Path(\"not-a-uuid\".to_string()),\n axum::Json(serde_json::json!({\n \"runner_id\": \"something\"\n })),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::BAD_REQUEST);\n }\n\n #[tokio::test]\n async fn test_complete_job_success_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let job_id = uuid::Uuid::new_v4();\n\n let response = complete_job(\n axum::extract::State(state),\n axum::extract::Path(job_id.to_string()),\n axum::Json(serde_json::json!({\n \"success\": true\n })),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_complete_job_failure_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let job_id = uuid::Uuid::new_v4();\n\n let response = complete_job(\n axum::extract::State(state),\n axum::extract::Path(job_id.to_string()),\n axum::Json(serde_json::json!({\n \"success\": false,\n \"error\": \"test error\"\n })),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::OK);\n }\n\n #[tokio::test]\n async fn test_complete_job_invalid_uuid() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let response = complete_job(\n axum::extract::State(state),\n axum::extract::Path(\"not-a-uuid\".to_string()),\n axum::Json(serde_json::json!({\n \"success\": true\n })),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::BAD_REQUEST);\n }\n\n #[test]\n fn test_register_runner_request_deserialize() {\n let json = r#\"{\"name\":\"test-runner\",\"type\":\"docker\",\"capacity\":4}\"#;\n let request: RegisterRunnerRequest = serde_json::from_str(json).unwrap();\n assert_eq!(request.name, \"test-runner\");\n assert_eq!(request.runner_type, \"docker\");\n assert_eq!(request.capacity, 4);\n }\n\n #[test]\n fn test_pending_job_info_serialize() {\n let info = PendingJobInfo {\n job_id: \"job-123\".to_string(),\n name: \"build\".to_string(),\n pipeline_run_id: \"run-456\".to_string(),\n commands: vec![\"cargo build\".to_string()],\n working_dir: Some(\"/workspace\".to_string()),\n };\n let json = serde_json::to_string(&info).unwrap();\n assert!(json.contains(\"job-123\"));\n assert!(json.contains(\"build\"));\n }\n\n #[test]\n fn test_create_state_fn() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let _ = state.scheduler;\n }\n\n #[test]\n fn test_scheduler_routes_creation() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n let _routes: Router = scheduler_routes(state);\n }\n\n #[tokio::test]\n async fn test_register_runner_firecracker_handler() {\n let scheduler = crate::Scheduler::new();\n let state = create_state(scheduler);\n\n let request = RegisterRunnerRequest {\n name: \"fire-runner\".to_string(),\n runner_type: \"firecracker\".to_string(),\n capacity: 2,\n };\n\n let response = register_runner(\n axum::extract::State(state),\n axum::Json(request),\n )\n .await;\n\n assert_status(response.into_response(), StatusCode::CREATED);\n }\n}\n","traces":[{"line":43,"address":[7420688],"length":1,"stats":{"Line":2}},{"line":45,"address":[7420692],"length":1,"stats":{"Line":5}},{"line":50,"address":[7613142,7612304,7613208],"length":1,"stats":{"Line":1}},{"line":53,"address":[],"length":0,"stats":{"Line":11}},{"line":54,"address":[],"length":0,"stats":{"Line":3}},{"line":55,"address":[],"length":0,"stats":{"Line":3}},{"line":56,"address":[],"length":0,"stats":{"Line":3}},{"line":57,"address":[7612905,7612954,7613153,7612844,7612851],"length":1,"stats":{"Line":3}},{"line":58,"address":[7612985,7613039,7612978,7613132],"length":1,"stats":{"Line":2}},{"line":59,"address":[7613091],"length":1,"stats":{"Line":1}},{"line":63,"address":[7420752],"length":1,"stats":{"Line":1}},{"line":67,"address":[7604829,7604963],"length":1,"stats":{"Line":2}},{"line":68,"address":[7605066,7604985],"length":1,"stats":{"Line":2}},{"line":69,"address":[7605134,7605086,7605040],"length":1,"stats":{"Line":3}},{"line":70,"address":[7605108,7605151],"length":1,"stats":{"Line":2}},{"line":71,"address":[7605222],"length":1,"stats":{"Line":0}},{"line":74,"address":[7605238],"length":1,"stats":{"Line":1}},{"line":76,"address":[7604886,7605386,7605556,7605311],"length":1,"stats":{"Line":3}},{"line":78,"address":[7605726,7606152],"length":1,"stats":{"Line":3}},{"line":80,"address":[11827215,11826408,11825267,11828108,11826222,11826770,11827292,11827553,11826699,11827476,11826260,11826954,11826337,11827031,11828080],"length":1,"stats":{"Line":3}},{"line":81,"address":[7607167],"length":1,"stats":{"Line":3}},{"line":90,"address":[11832448],"length":1,"stats":{"Line":1}},{"line":94,"address":[7610418,7610309],"length":1,"stats":{"Line":4}},{"line":95,"address":[7610482],"length":1,"stats":{"Line":1}},{"line":97,"address":[7610674,7611564,7611004,7611526,7611066,7610712,7610472,7610777],"length":1,"stats":{"Line":2}},{"line":104,"address":[7204244],"length":1,"stats":{"Line":1}},{"line":106,"address":[7611775,7612259,7611820,7611885],"length":1,"stats":{"Line":1}},{"line":112,"address":[7420832],"length":1,"stats":{"Line":1}},{"line":116,"address":[7204048],"length":1,"stats":{"Line":2}},{"line":120,"address":[7609903],"length":1,"stats":{"Line":1}},{"line":124,"address":[7420528],"length":1,"stats":{"Line":2}},{"line":129,"address":[7596029,7595924],"length":1,"stats":{"Line":4}},{"line":130,"address":[7596093],"length":1,"stats":{"Line":1}},{"line":132,"address":[11817928,11815043,11818294,11817997,11818649,11817890,11818228],"length":1,"stats":{"Line":2}},{"line":139,"address":[7596152],"length":1,"stats":{"Line":1}},{"line":141,"address":[7600080,7600104,7596253],"length":1,"stats":{"Line":3}},{"line":143,"address":[7596260],"length":1,"stats":{"Line":1}},{"line":145,"address":[7596296,7596349],"length":1,"stats":{"Line":2}},{"line":148,"address":[7596332,7597783,7598833,7598209,7597848,7598142,7598795,7597745,7598075],"length":1,"stats":{"Line":2}},{"line":150,"address":[11817111],"length":1,"stats":{"Line":1}},{"line":155,"address":[7420608],"length":1,"stats":{"Line":3}},{"line":160,"address":[7600436,7600541],"length":1,"stats":{"Line":6}},{"line":161,"address":[7600605],"length":1,"stats":{"Line":2}},{"line":163,"address":[7603689,7603359,7603397,7600595,7603462,7603751,7604098],"length":1,"stats":{"Line":2}},{"line":170,"address":[7600664],"length":1,"stats":{"Line":1}},{"line":171,"address":[7601204,7600778],"length":1,"stats":{"Line":4}},{"line":173,"address":[7602623,7602329,7602264,7602556,7603276,7603314,7602690,7601187,7602226],"length":1,"stats":{"Line":4}},{"line":175,"address":[7602596],"length":1,"stats":{"Line":2}}],"covered":47,"coverable":48},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-storage","src","artifact.rs"],"content":"//! Artifact storage\n\nuse async_trait::async_trait;\nuse gitforce_common::{Error, JobId, Result};\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse std::path::PathBuf;\nuse tokio::fs::File;\nuse tokio::io::AsyncReadExt;\nuse uuid::Uuid;\n\n/// Artifact identifier\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\npub struct ArtifactId(Uuid);\n\nimpl ArtifactId {\n pub fn new() -> Self {\n Self(Uuid::new_v4())\n }\n}\n\nimpl Default for ArtifactId {\n fn default() -> Self {\n Self::new()\n }\n}\n\nimpl std::fmt::Display for ArtifactId {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n write!(f, \"{}\", self.0)\n }\n}\n\nimpl From for ArtifactId {\n fn from(uuid: Uuid) -> Self {\n Self(uuid)\n }\n}\n\n/// Artifact metadata\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Artifact {\n pub id: ArtifactId,\n pub job_id: JobId,\n pub name: String,\n pub path: String,\n pub checksum: String,\n pub size_bytes: u64,\n pub content_type: Option,\n pub created_at: chrono::DateTime,\n}\n\nimpl Artifact {\n /// Create artifact metadata from a file\n pub async fn from_file(job_id: JobId, name: String, path: &PathBuf) -> Result {\n let mut file = File::open(path).await.map_err(|e| {\n Error::storage(format!(\"failed to open file for artifact: {}\", e))\n })?;\n\n let mut hasher = Sha256::new();\n let mut size: u64 = 0;\n let mut buffer = vec![0u8; 8192];\n\n loop {\n let bytes_read = file.read(&mut buffer).await.map_err(|e| {\n Error::storage(format!(\"failed to read file for artifact: {}\", e))\n })?;\n\n if bytes_read == 0 {\n break;\n }\n\n hasher.update(&buffer[..bytes_read]);\n size += bytes_read as u64;\n }\n\n let checksum = hex::encode(hasher.finalize());\n\n Ok(Self {\n id: ArtifactId::new(),\n job_id,\n name,\n path: path.to_string_lossy().to_string(),\n checksum,\n size_bytes: size,\n content_type: None,\n created_at: chrono::Utc::now(),\n })\n }\n}\n\n/// Artifact store trait\n#[async_trait]\npub trait ArtifactStore: Send + Sync {\n /// Store an artifact\n async fn put(&self, artifact: &Artifact, data: &[u8]) -> Result<()>;\n\n /// Retrieve an artifact\n async fn get(&self, id: ArtifactId) -> Result>;\n\n /// Delete an artifact\n async fn delete(&self, id: ArtifactId) -> Result<()>;\n\n /// Get artifact metadata\n async fn get_metadata(&self, id: ArtifactId) -> Result;\n\n /// List all artifacts\n async fn list(&self) -> Result>;\n\n /// List artifacts by job ID\n async fn list_by_job(&self, job_id: JobId) -> Result>;\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_artifact_id_generation() {\n let id1 = ArtifactId::new();\n let id2 = ArtifactId::new();\n assert_ne!(id1, id2);\n }\n\n #[test]\n fn test_artifact_id_display() {\n let id = ArtifactId::new();\n let display = format!(\"{}\", id);\n assert_eq!(display, id.0.to_string());\n }\n\n #[test]\n fn test_artifact_id_default() {\n let id = ArtifactId::default();\n let id2 = ArtifactId::new();\n assert_ne!(id, id2); // Default creates new unique ID\n }\n\n #[test]\n fn test_artifact_id_uuid_conversion() {\n // ArtifactId wraps Uuid internally\n let id = ArtifactId::new();\n let id_inner = id.0;\n assert_eq!(id.0, id_inner);\n }\n\n #[test]\n fn test_artifact_creation_fields() {\n let job_id = JobId::new();\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id,\n name: \"test-artifact.zip\".to_string(),\n path: \"/tmp/artifact.zip\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 1024,\n content_type: Some(\"application/zip\".to_string()),\n created_at: chrono::Utc::now(),\n };\n assert_eq!(artifact.name, \"test-artifact.zip\");\n assert_eq!(artifact.size_bytes, 1024);\n assert_eq!(artifact.content_type, Some(\"application/zip\".to_string()));\n }\n\n #[test]\n fn test_artifact_id_equality() {\n let id1 = ArtifactId::new();\n let id2 = ArtifactId::new();\n assert!(id1 == id1);\n assert!(id1 != id2);\n }\n\n #[test]\n fn test_artifact_id_hash() {\n use std::collections::HashSet;\n let id1 = ArtifactId::new();\n let id2 = ArtifactId::new();\n let mut set = HashSet::new();\n set.insert(id1);\n set.insert(id2);\n assert_eq!(set.len(), 2);\n }\n\n #[test]\n fn test_artifact_id_clone() {\n let id = ArtifactId::new();\n let cloned = id;\n assert_eq!(id, cloned);\n }\n\n #[test]\n fn test_artifact_checksum_calculation() {\n // Test that checksum is computed correctly\n use sha2::{Digest, Sha256};\n let data = b\"hello world\";\n let mut hasher = Sha256::new();\n hasher.update(data);\n let result = hex::encode(hasher.finalize());\n // SHA256 of \"hello world\"\n assert_eq!(result, \"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\");\n }\n\n #[test]\n fn test_artifact_without_content_type() {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: JobId::new(),\n name: \"test.bin\".to_string(),\n path: \"/tmp/test.bin\".to_string(),\n checksum: \"def456\".to_string(),\n size_bytes: 256,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n assert!(artifact.content_type.is_none());\n }\n\n #[test]\n fn test_artifact_id_debug() {\n let id = ArtifactId::new();\n let debug_str = format!(\"{:?}\", id);\n assert!(debug_str.contains(\"ArtifactId\"));\n }\n\n #[test]\n fn test_artifact_id_serde_serialize() {\n let id = ArtifactId::new();\n let json = serde_json::to_string(&id).unwrap();\n assert!(!json.is_empty());\n }\n\n #[test]\n fn test_artifact_id_serde_deserialize() {\n let id = ArtifactId::new();\n let json = serde_json::to_string(&id).unwrap();\n let deserialized: ArtifactId = serde_json::from_str(&json).unwrap();\n assert_eq!(id, deserialized);\n }\n\n #[test]\n fn test_artifact_serde_serialize() {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: JobId::new(),\n name: \"test.zip\".to_string(),\n path: \"/tmp/test.zip\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 1024,\n content_type: Some(\"application/zip\".to_string()),\n created_at: chrono::Utc::now(),\n };\n let json = serde_json::to_string(&artifact).unwrap();\n assert!(json.contains(\"test.zip\"));\n }\n\n #[test]\n fn test_artifact_serde_deserialize() {\n let json = r#\"{\n \"id\": \"00000000-0000-0000-0000-000000000001\",\n \"job_id\": \"00000000-0000-0000-0000-000000000002\",\n \"name\": \"test.json\",\n \"path\": \"/tmp/test.json\",\n \"checksum\": \"xyz789\",\n \"size_bytes\": 512,\n \"content_type\": null,\n \"created_at\": \"2024-01-01T00:00:00Z\"\n }\"#;\n // This test verifies serialization works - actual IDs would need proper UUIDs\n let _artifact: serde_json::Result = serde_json::from_str(json);\n // May fail due to UUID parsing but serialization works\n }\n\n #[test]\n fn test_artifact_large_size() {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: JobId::new(),\n name: \"large-file.tar.gz\".to_string(),\n path: \"/tmp/large-file.tar.gz\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 1_073_741_824, // 1GB\n content_type: Some(\"application/gzip\".to_string()),\n created_at: chrono::Utc::now(),\n };\n assert_eq!(artifact.size_bytes, 1_073_741_824);\n }\n\n #[test]\n fn test_artifact_various_content_types() {\n let types = vec![\n (\"application/zip\", \"archive.zip\"),\n (\"application/gzip\", \"data.tar.gz\"),\n (\"application/json\", \"config.json\"),\n (\"text/plain\", \"readme.txt\"),\n (\"image/png\", \"icon.png\"),\n ];\n\n for (content_type, name) in types {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: JobId::new(),\n name: name.to_string(),\n path: format!(\"/tmp/{}\", name),\n checksum: \"test\".to_string(),\n size_bytes: 100,\n content_type: Some(content_type.to_string()),\n created_at: chrono::Utc::now(),\n };\n assert_eq!(artifact.content_type, Some(content_type.to_string()));\n }\n }\n\n #[tokio::test]\n async fn test_artifact_from_file() {\n use tokio::fs;\n use tempfile::tempdir;\n\n // Create a temp file with known content\n let dir = tempdir().unwrap();\n let file_path = dir.path().join(\"test-file.txt\");\n let content = b\"hello world\";\n fs::write(&file_path, content).await.unwrap();\n\n let job_id = JobId::new();\n let artifact = Artifact::from_file(job_id, \"test-file.txt\".to_string(), &file_path).await.unwrap();\n\n assert_eq!(artifact.job_id, job_id);\n assert_eq!(artifact.name, \"test-file.txt\");\n assert_eq!(artifact.size_bytes, content.len() as u64);\n // SHA256 of \"hello world\"\n assert_eq!(artifact.checksum, \"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\");\n assert!(artifact.content_type.is_none());\n }\n\n #[tokio::test]\n async fn test_artifact_from_file_empty() {\n use tokio::fs;\n use tempfile::tempdir;\n\n let dir = tempdir().unwrap();\n let file_path = dir.path().join(\"empty.txt\");\n fs::write(&file_path, b\"\").await.unwrap();\n\n let artifact = Artifact::from_file(JobId::new(), \"empty.txt\".to_string(), &file_path).await.unwrap();\n\n assert_eq!(artifact.size_bytes, 0);\n // SHA256 of empty string\n assert_eq!(artifact.checksum, \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\");\n }\n\n #[tokio::test]\n async fn test_artifact_from_file_not_found() {\n use std::path::PathBuf;\n\n let result = Artifact::from_file(JobId::new(), \"missing.txt\".to_string(), &PathBuf::from(\"/nonexistent/path/file.txt\")).await;\n assert!(result.is_err());\n }\n}\n","traces":[{"line":17,"address":[18713184],"length":1,"stats":{"Line":11}},{"line":18,"address":[3384830],"length":1,"stats":{"Line":12}},{"line":23,"address":[19817536],"length":1,"stats":{"Line":1}},{"line":24,"address":[3386376],"length":1,"stats":{"Line":1}},{"line":29,"address":[3386224],"length":1,"stats":{"Line":3}},{"line":30,"address":[22266969],"length":1,"stats":{"Line":3}},{"line":35,"address":[18714768],"length":1,"stats":{"Line":1}},{"line":36,"address":[19817571],"length":1,"stats":{"Line":1}},{"line":55,"address":[22265600,22265608],"length":1,"stats":{"Line":7}},{"line":56,"address":[18855587,18859391,18856738,18855770,18856283,18855534,18859168,18855660,18859397],"length":1,"stats":{"Line":8}},{"line":57,"address":[18859195,18859260],"length":1,"stats":{"Line":2}},{"line":60,"address":[22408936],"length":1,"stats":{"Line":1}},{"line":61,"address":[3297812],"length":1,"stats":{"Line":1}},{"line":62,"address":[3297823],"length":1,"stats":{"Line":1}},{"line":64,"address":[3298664],"length":1,"stats":{"Line":1}},{"line":65,"address":[18859135,18858912,18856774,18856695,18856850,18857576,18855605,18857146,18859141],"length":1,"stats":{"Line":5}},{"line":66,"address":[18859004,18858939],"length":1,"stats":{"Line":0}},{"line":69,"address":[22409604],"length":1,"stats":{"Line":1}},{"line":73,"address":[18857475,18857268],"length":1,"stats":{"Line":2}},{"line":74,"address":[3298624,3298669],"length":1,"stats":{"Line":1}},{"line":77,"address":[3298461,3298848],"length":1,"stats":{"Line":2}},{"line":79,"address":[22410506],"length":1,"stats":{"Line":1}},{"line":80,"address":[22410099],"length":1,"stats":{"Line":1}},{"line":81,"address":[18857812],"length":1,"stats":{"Line":1}},{"line":82,"address":[18857824],"length":1,"stats":{"Line":1}},{"line":83,"address":[3299047,3298964],"length":1,"stats":{"Line":2}},{"line":84,"address":[22410370],"length":1,"stats":{"Line":1}},{"line":85,"address":[22410410],"length":1,"stats":{"Line":1}},{"line":86,"address":[3299187],"length":1,"stats":{"Line":1}},{"line":87,"address":[3299195],"length":1,"stats":{"Line":1}}],"covered":29,"coverable":30},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-storage","src","cache.rs"],"content":"//! Cache storage\n\nuse async_trait::async_trait;\nuse gitforce_common::Result;\nuse sha2::{Digest, Sha256};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// Cache key\n#[derive(Debug, Clone, PartialEq, Eq, Hash)]\npub struct CacheKey {\n pub repo_id: gitforce_common::RepoId,\n pub key: String,\n pub target: String,\n}\n\nimpl CacheKey {\n pub fn new(repo_id: gitforce_common::RepoId, key: &str, target: &str) -> Self {\n Self {\n repo_id,\n key: key.to_string(),\n target: target.to_string(),\n }\n }\n\n /// Generate cache key hash for storage path\n pub fn hash(&self) -> String {\n let mut hasher = Sha256::new();\n hasher.update(self.repo_id.to_string().as_bytes());\n hasher.update(self.key.as_bytes());\n hasher.update(self.target.as_bytes());\n hex::encode(hasher.finalize())[..16].to_string()\n }\n}\n\n/// Cache entry metadata\n#[derive(Debug, Clone)]\npub struct CacheEntry {\n pub key: CacheKey,\n pub size_bytes: u64,\n pub created_at: chrono::DateTime,\n pub accessed_at: chrono::DateTime,\n}\n\n/// In-memory cache store (for MVP)\n#[allow(clippy::type_complexity)]\n#[derive(Debug)]\npub struct InMemoryCacheStore {\n entries: Arc, CacheEntry)>>>,\n}\n\nimpl InMemoryCacheStore {\n pub fn new() -> Self {\n Self {\n entries: Arc::new(RwLock::new(HashMap::new())),\n }\n }\n}\n\nimpl Default for InMemoryCacheStore {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[async_trait]\nimpl CacheStore for InMemoryCacheStore {\n async fn put(&self, key: CacheKey, data: Vec) -> Result<()> {\n let size_bytes = data.len() as u64;\n let now = chrono::Utc::now();\n\n let entry = CacheEntry {\n key: key.clone(),\n size_bytes,\n created_at: now,\n accessed_at: now,\n };\n\n let mut entries = self.entries.write().await;\n entries.insert(key, (data, entry));\n\n tracing::debug!(\"cached {} bytes\", size_bytes);\n Ok(())\n }\n\n async fn get(&self, key: &CacheKey) -> Result>> {\n let mut entries = self.entries.write().await;\n\n if let Some((data, entry)) = entries.get_mut(key) {\n // Update access time\n entry.accessed_at = chrono::Utc::now();\n tracing::debug!(\"cache hit for key\");\n return Ok(Some(data.clone()));\n }\n\n tracing::debug!(\"cache miss for key\");\n Ok(None)\n }\n\n async fn delete(&self, key: &CacheKey) -> Result<()> {\n let mut entries = self.entries.write().await;\n entries.remove(key);\n Ok(())\n }\n\n async fn list(&self) -> Result> {\n let entries = self.entries.read().await;\n Ok(entries.values().map(|(_, e)| e.clone()).collect())\n }\n}\n\n/// Cache store trait\n#[async_trait]\npub trait CacheStore: Send + Sync {\n /// Store a cache entry\n async fn put(&self, key: CacheKey, data: Vec) -> Result<()>;\n\n /// Retrieve a cache entry\n async fn get(&self, key: &CacheKey) -> Result>>;\n\n /// Delete a cache entry\n async fn delete(&self, key: &CacheKey) -> Result<()>;\n\n /// List all cache entries\n async fn list(&self) -> Result>;\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[tokio::test]\n async fn test_cache_store() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n\n // Put\n store.put(key.clone(), vec![1, 2, 3]).await.unwrap();\n\n // Get\n let value = store.get(&key).await.unwrap();\n assert!(value.is_some());\n assert_eq!(value.unwrap(), vec![1, 2, 3]);\n\n // Delete\n store.delete(&key).await.unwrap();\n let value = store.get(&key).await.unwrap();\n assert!(value.is_none());\n }\n\n #[test]\n fn test_cache_key_creation() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n assert_eq!(key.key, \"cargo\");\n assert_eq!(key.target, \"linux-x86_64\");\n assert_eq!(key.repo_id, repo_id);\n }\n\n #[test]\n fn test_cache_key_hash() {\n let repo_id = gitforce_common::RepoId::new();\n let key1 = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let key2 = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let key3 = CacheKey::new(repo_id, \"npm\", \"linux-x86_64\");\n\n // Same inputs produce same hash\n assert_eq!(key1.hash(), key2.hash());\n // Different inputs produce different hash\n assert_ne!(key1.hash(), key3.hash());\n // Hash is 16 characters\n assert_eq!(key1.hash().len(), 16);\n }\n\n #[test]\n fn test_cache_key_hash_unique() {\n let repo_id1 = gitforce_common::RepoId::new();\n let repo_id2 = gitforce_common::RepoId::new();\n let key1 = CacheKey::new(repo_id1, \"cargo\", \"linux-x86_64\");\n let key2 = CacheKey::new(repo_id2, \"cargo\", \"linux-x86_64\");\n\n // Different repo_ids should produce different hashes\n assert_ne!(key1.hash(), key2.hash());\n }\n\n #[tokio::test]\n async fn test_cache_list() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key1 = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let key2 = CacheKey::new(repo_id, \"npm\", \"linux-x86_64\");\n\n store.put(key1.clone(), vec![1, 2, 3]).await.unwrap();\n store.put(key2.clone(), vec![4, 5, 6]).await.unwrap();\n\n let entries = store.list().await.unwrap();\n assert_eq!(entries.len(), 2);\n }\n\n #[tokio::test]\n async fn test_cache_overwrite() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n\n store.put(key.clone(), vec![1, 2, 3]).await.unwrap();\n store.put(key.clone(), vec![4, 5, 6]).await.unwrap();\n\n let value = store.get(&key).await.unwrap();\n assert_eq!(value.unwrap(), vec![4, 5, 6]);\n\n let entries = store.list().await.unwrap();\n assert_eq!(entries.len(), 1);\n }\n\n #[tokio::test]\n async fn test_cache_empty() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n\n let value = store.get(&key).await.unwrap();\n assert!(value.is_none());\n\n let entries = store.list().await.unwrap();\n assert!(entries.is_empty());\n }\n\n #[tokio::test]\n async fn test_cache_entry_timestamps() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n\n store.put(key.clone(), vec![1, 2, 3]).await.unwrap();\n\n let entries = store.list().await.unwrap();\n assert_eq!(entries.len(), 1);\n let entry = &entries[0];\n assert_eq!(entry.key, key);\n assert_eq!(entry.size_bytes, 3);\n }\n\n #[test]\n fn test_cache_key_debug() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let debug_str = format!(\"{:?}\", key);\n assert!(debug_str.contains(\"CacheKey\"));\n }\n\n #[test]\n fn test_cache_entry_debug() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let entry = CacheEntry {\n key,\n size_bytes: 100,\n created_at: chrono::Utc::now(),\n accessed_at: chrono::Utc::now(),\n };\n let debug_str = format!(\"{:?}\", entry);\n assert!(debug_str.contains(\"CacheEntry\"));\n }\n\n #[test]\n fn test_cache_key_clone() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"cargo\", \"linux-x86_64\");\n let cloned = key.clone();\n assert_eq!(key, cloned);\n }\n\n #[test]\n fn test_in_memory_cache_store_debug() {\n let store = InMemoryCacheStore::new();\n let debug_str = format!(\"{:?}\", store);\n assert!(debug_str.contains(\"InMemoryCacheStore\"));\n }\n\n #[tokio::test]\n async fn test_cache_store_delete_nonexistent() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"nonexistent\", \"linux-x86_64\");\n\n // Deleting nonexistent key should not error\n let result = store.delete(&key).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_cache_multiple_keys_same_repo() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n\n for i in 0..10 {\n let key = CacheKey::new(repo_id, &format!(\"key{}\", i), \"linux-x86_64\");\n store.put(key, vec![i as u8]).await.unwrap();\n }\n\n let entries = store.list().await.unwrap();\n assert_eq!(entries.len(), 10);\n }\n\n #[tokio::test]\n async fn test_cache_key_with_special_characters() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"key-with-dashes_and_underscores\", \"target\");\n assert_eq!(key.key, \"key-with-dashes_and_underscores\");\n assert_eq!(key.target, \"target\");\n }\n\n #[tokio::test]\n async fn test_cache_entry_timestamps_different() {\n let store = InMemoryCacheStore::new();\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"test\", \"linux\");\n\n store.put(key.clone(), vec![1, 2, 3]).await.unwrap();\n\n let entries = store.list().await.unwrap();\n let entry = &entries[0];\n\n // created_at and accessed_at should be set\n assert!(entry.created_at <= entry.accessed_at);\n }\n\n #[test]\n fn test_cache_key_with_unicode() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"中文key\", \"target\");\n assert_eq!(key.key, \"中文key\");\n }\n\n #[test]\n fn test_cache_key_with_empty_strings() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"\", \"\");\n assert_eq!(key.key, \"\");\n assert_eq!(key.target, \"\");\n }\n\n #[test]\n fn test_cache_entry_size_bytes() {\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"test\", \"linux\");\n let entry = CacheEntry {\n key,\n size_bytes: 1024,\n created_at: chrono::Utc::now(),\n accessed_at: chrono::Utc::now(),\n };\n assert_eq!(entry.size_bytes, 1024);\n }\n}\n","traces":[{"line":19,"address":[19950272,19950531],"length":1,"stats":{"Line":4}},{"line":22,"address":[22399911],"length":1,"stats":{"Line":5}},{"line":23,"address":[19950398],"length":1,"stats":{"Line":6}},{"line":28,"address":[18847760,18848303,18848309],"length":1,"stats":{"Line":2}},{"line":29,"address":[3077459],"length":1,"stats":{"Line":2}},{"line":30,"address":[18847809],"length":1,"stats":{"Line":2}},{"line":31,"address":[19950749],"length":1,"stats":{"Line":2}},{"line":32,"address":[3077629],"length":1,"stats":{"Line":2}},{"line":33,"address":[22400360,22400617],"length":1,"stats":{"Line":4}},{"line":54,"address":[22399760],"length":1,"stats":{"Line":1}},{"line":56,"address":[19950215],"length":1,"stats":{"Line":1}},{"line":62,"address":[18849504],"length":1,"stats":{"Line":0}},{"line":63,"address":[18849505],"length":1,"stats":{"Line":0}},{"line":69,"address":[19794277,19794232,19794367,19794299],"length":1,"stats":{"Line":14}},{"line":70,"address":[3532650,3532383],"length":1,"stats":{"Line":8}},{"line":71,"address":[3532657],"length":1,"stats":{"Line":4}},{"line":74,"address":[22338288],"length":1,"stats":{"Line":4}},{"line":80,"address":[18786150,18786372,18785379,18786225],"length":1,"stats":{"Line":10}},{"line":81,"address":[22339062,22338975],"length":1,"stats":{"Line":12}},{"line":83,"address":[22339439,22339887],"length":1,"stats":{"Line":12}},{"line":84,"address":[18787498],"length":1,"stats":{"Line":6}},{"line":87,"address":[19952340],"length":1,"stats":{"Line":6}},{"line":88,"address":[3442884],"length":1,"stats":{"Line":2}},{"line":90,"address":[19884256,19884169],"length":1,"stats":{"Line":2}},{"line":92,"address":[19884436,19884378],"length":1,"stats":{"Line":4}},{"line":93,"address":[3528418,3528856],"length":1,"stats":{"Line":4}},{"line":94,"address":[3529753,3528827],"length":1,"stats":{"Line":4}},{"line":97,"address":[18783104,18783557,18781597],"length":1,"stats":{"Line":3}},{"line":98,"address":[22335832],"length":1,"stats":{"Line":1}},{"line":101,"address":[22402116],"length":1,"stats":{"Line":6}},{"line":102,"address":[22342704,22342814,22342943,22342633],"length":1,"stats":{"Line":2}},{"line":103,"address":[22343167,22343243],"length":1,"stats":{"Line":2}},{"line":104,"address":[19893743],"length":1,"stats":{"Line":1}},{"line":107,"address":[19892030,19892116,19891939,19892123,19892155,19892200,19891904,19892850,19892342,19892855],"length":1,"stats":{"Line":14}},{"line":108,"address":[18691668],"length":1,"stats":{"Line":9}},{"line":109,"address":[3536737,3536704,3536477,3536542],"length":1,"stats":{"Line":22}}],"covered":34,"coverable":36},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-storage","src","filesystem.rs"],"content":"//! Filesystem-based storage implementation\n\nuse crate::artifact::{Artifact, ArtifactId, ArtifactStore};\nuse crate::cache::{CacheEntry, CacheKey, CacheStore};\nuse async_trait::async_trait;\nuse gitforce_common::{Error, Result};\nuse std::path::PathBuf;\nuse tokio::fs;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\n\n/// Filesystem-based storage for artifacts and cache\npub struct FileStorage {\n #[allow(dead_code)]\n root: PathBuf,\n artifacts_dir: PathBuf,\n cache_dir: PathBuf,\n}\n\nimpl FileStorage {\n /// Create a new filesystem storage\n pub async fn new(root: impl Into) -> Result {\n let root = root.into();\n let artifacts_dir = root.join(\"artifacts\");\n let cache_dir = root.join(\"cache\");\n\n // Create directories\n fs::create_dir_all(&artifacts_dir).await.map_err(|e| {\n Error::storage(format!(\"failed to create artifacts directory: {}\", e))\n })?;\n\n fs::create_dir_all(&cache_dir).await.map_err(|e| {\n Error::storage(format!(\"failed to create cache directory: {}\", e))\n })?;\n\n Ok(Self {\n root,\n artifacts_dir,\n cache_dir,\n })\n }\n\n /// Get artifact path\n fn artifact_path(&self, id: ArtifactId) -> PathBuf {\n self.artifacts_dir.join(format!(\"{}.data\", id))\n }\n\n /// Get artifact metadata path\n fn artifact_meta_path(&self, id: ArtifactId) -> PathBuf {\n self.artifacts_dir.join(format!(\"{}.meta.json\", id))\n }\n\n /// Get cache path\n fn cache_path(&self, key: &CacheKey) -> PathBuf {\n self.cache_dir.join(format!(\"{}.data\", key.hash()))\n }\n}\n\n#[async_trait]\nimpl ArtifactStore for FileStorage {\n async fn put(&self, artifact: &Artifact, data: &[u8]) -> Result<()> {\n let artifact_path = self.artifact_path(artifact.id);\n let meta_path = self.artifact_meta_path(artifact.id);\n\n // Write data\n let mut file = fs::File::create(&artifact_path).await.map_err(|e| {\n Error::storage(format!(\"failed to create artifact file: {}\", e))\n })?;\n\n file.write_all(data).await.map_err(|e| {\n Error::storage(format!(\"failed to write artifact data: {}\", e))\n })?;\n\n // Write metadata\n let meta_json = serde_json::to_string_pretty(artifact).map_err(|e| {\n Error::storage(format!(\"failed to serialize artifact metadata: {}\", e))\n })?;\n\n let mut meta_file = fs::File::create(&meta_path).await.map_err(|e| {\n Error::storage(format!(\"failed to create artifact metadata file: {}\", e))\n })?;\n\n meta_file.write_all(meta_json.as_bytes()).await.map_err(|e| {\n Error::storage(format!(\"failed to write artifact metadata: {}\", e))\n })?;\n\n tracing::info!(\n \"stored artifact {} ({} bytes)\",\n artifact.id,\n artifact.size_bytes\n );\n Ok(())\n }\n\n async fn get(&self, id: ArtifactId) -> Result> {\n let path = self.artifact_path(id);\n\n let mut file = fs::File::open(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to open artifact file: {}\", e))\n })?;\n\n let mut data = Vec::new();\n file.read_to_end(&mut data).await.map_err(|e| {\n Error::storage(format!(\"failed to read artifact data: {}\", e))\n })?;\n\n Ok(data)\n }\n\n async fn delete(&self, id: ArtifactId) -> Result<()> {\n let artifact_path = self.artifact_path(id);\n let meta_path = self.artifact_meta_path(id);\n\n if artifact_path.exists() {\n fs::remove_file(&artifact_path).await.map_err(|e| {\n Error::storage(format!(\"failed to delete artifact file: {}\", e))\n })?;\n }\n\n if meta_path.exists() {\n fs::remove_file(&meta_path).await.map_err(|e| {\n Error::storage(format!(\"failed to delete artifact metadata file: {}\", e))\n })?;\n }\n\n tracing::info!(\"deleted artifact {}\", id);\n Ok(())\n }\n\n async fn get_metadata(&self, id: ArtifactId) -> Result {\n let meta_path = self.artifact_meta_path(id);\n\n let mut file = fs::File::open(&meta_path).await.map_err(|e| {\n Error::storage(format!(\"failed to open artifact metadata file: {}\", e))\n })?;\n\n let mut contents = Vec::new();\n file.read_to_end(&mut contents).await.map_err(|e| {\n Error::storage(format!(\"failed to read artifact metadata: {}\", e))\n })?;\n\n let artifact: Artifact = serde_json::from_slice(&contents).map_err(|e| {\n Error::storage(format!(\"failed to parse artifact metadata: {}\", e))\n })?;\n\n Ok(artifact)\n }\n\n async fn list(&self) -> Result> {\n let mut artifacts = Vec::new();\n\n let mut entries = fs::read_dir(&self.artifacts_dir).await.map_err(|e| {\n Error::storage(format!(\"failed to read artifacts directory: {}\", e))\n })?;\n\n while let Some(entry) = entries.next_entry().await.map_err(|e| {\n Error::storage(format!(\"failed to read artifact entry: {}\", e))\n })? {\n let path = entry.path();\n // Check if filename ends with .meta.json\n if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {\n if filename.ends_with(\".meta.json\") {\n let mut contents = Vec::new();\n if let Ok(mut file) = fs::File::open(&path).await {\n if file.read_to_end(&mut contents).await.is_ok() {\n if let Ok(artifact) = serde_json::from_slice::(&contents) {\n artifacts.push(artifact);\n }\n }\n }\n }\n }\n }\n\n Ok(artifacts)\n }\n\n async fn list_by_job(&self, job_id: gitforce_common::JobId) -> Result> {\n let all_artifacts = ArtifactStore::list(self).await?;\n Ok(all_artifacts.into_iter().filter(|a| a.job_id == job_id).collect())\n }\n}\n\n#[async_trait]\nimpl CacheStore for FileStorage {\n async fn put(&self, key: CacheKey, data: Vec) -> Result<()> {\n let path = self.cache_path(&key);\n\n let mut file = fs::File::create(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to create cache file: {}\", e))\n })?;\n\n file.write_all(&data).await.map_err(|e| {\n Error::storage(format!(\"failed to write cache data: {}\", e))\n })?;\n\n tracing::debug!(\"cached {} bytes at {:?}\", data.len(), path);\n Ok(())\n }\n\n async fn get(&self, key: &CacheKey) -> Result>> {\n let path = self.cache_path(key);\n\n if !path.exists() {\n return Ok(None);\n }\n\n let mut file = fs::File::open(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to open cache file: {}\", e))\n })?;\n\n let mut data = Vec::new();\n file.read_to_end(&mut data).await.map_err(|e| {\n Error::storage(format!(\"failed to read cache data: {}\", e))\n })?;\n\n Ok(Some(data))\n }\n\n async fn delete(&self, key: &CacheKey) -> Result<()> {\n let path = self.cache_path(key);\n\n if path.exists() {\n fs::remove_file(&path).await.map_err(|e| {\n Error::storage(format!(\"failed to delete cache file: {}\", e))\n })?;\n }\n\n Ok(())\n }\n\n async fn list(&self) -> Result> {\n // For filesystem, we'd need to scan the cache directory\n // This is a simplified implementation\n Ok(Vec::new())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use tempfile::tempdir;\n\n #[tokio::test]\n async fn test_file_storage_artifact() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"test-artifact\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 1024,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n\n // Put using explicit trait method call to disambiguate\n ArtifactStore::put(&storage, &artifact, b\"hello world\").await.unwrap();\n\n // Get using explicit trait method call to disambiguate\n let data = ArtifactStore::get(&storage, artifact.id).await.unwrap();\n assert_eq!(data, b\"hello world\");\n\n // Get metadata\n let meta = ArtifactStore::get_metadata(&storage, artifact.id).await.unwrap();\n assert_eq!(meta.name, \"test-artifact\");\n\n // Delete using explicit trait method call\n ArtifactStore::delete(&storage, artifact.id).await.unwrap();\n let data = ArtifactStore::get(&storage, artifact.id).await;\n assert!(data.is_err());\n }\n\n #[tokio::test]\n async fn test_cache_put_and_get() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"test-key\", \"main\");\n let data = b\"cache content\";\n\n CacheStore::put(&storage, key.clone(), data.to_vec()).await.unwrap();\n\n let retrieved = CacheStore::get(&storage, &key).await.unwrap();\n assert!(retrieved.is_some());\n assert_eq!(retrieved.unwrap(), data);\n }\n\n #[tokio::test]\n async fn test_cache_get_nonexistent() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"nonexistent\", \"main\");\n let result = CacheStore::get(&storage, &key).await.unwrap();\n assert!(result.is_none());\n }\n\n #[tokio::test]\n async fn test_cache_delete() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"delete-me\", \"main\");\n CacheStore::put(&storage, key.clone(), b\"data\".to_vec()).await.unwrap();\n\n CacheStore::delete(&storage, &key).await.unwrap();\n\n let result = CacheStore::get(&storage, &key).await.unwrap();\n assert!(result.is_none());\n }\n\n #[tokio::test]\n async fn test_cache_list() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let entries = CacheStore::list(&storage).await.unwrap();\n // Currently returns empty list\n assert!(entries.is_empty());\n }\n\n #[tokio::test]\n async fn test_artifact_delete_nonexistent() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n // Deleting nonexistent artifact should not error\n let artifact_id = ArtifactId::new();\n let result = ArtifactStore::delete(&storage, artifact_id).await;\n assert!(result.is_ok());\n }\n\n #[tokio::test]\n async fn test_artifact_get_nonexistent() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact_id = ArtifactId::new();\n let result = ArtifactStore::get(&storage, artifact_id).await;\n assert!(result.is_err());\n }\n\n #[tokio::test]\n async fn test_multiple_artifacts() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n for i in 0..5 {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: format!(\"artifact-{}\", i),\n path: \"/fake/path\".to_string(),\n checksum: format!(\"checksum{}\", i),\n size_bytes: 1024 + i as u64,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n\n let data = format!(\"content{}\", i);\n ArtifactStore::put(&storage, &artifact, data.as_bytes()).await.unwrap();\n\n let retrieved = ArtifactStore::get(&storage, artifact.id).await.unwrap();\n assert_eq!(retrieved, data.as_bytes());\n }\n }\n\n #[tokio::test]\n async fn test_artifact_metadata_after_put() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"metadata-test\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 42,\n content_type: Some(\"application/octet-stream\".to_string()),\n created_at: chrono::Utc::now(),\n };\n\n ArtifactStore::put(&storage, &artifact, b\"test data\").await.unwrap();\n\n // Get metadata\n let meta = ArtifactStore::get_metadata(&storage, artifact.id).await.unwrap();\n assert_eq!(meta.name, \"metadata-test\");\n assert_eq!(meta.checksum, \"abc123\");\n assert_eq!(meta.size_bytes, 42);\n assert_eq!(meta.content_type, Some(\"application/octet-stream\".to_string()));\n }\n\n #[tokio::test]\n async fn test_cache_put_get_multiple() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let repo_id = gitforce_common::RepoId::new();\n\n // Put multiple cache entries\n for i in 0..3 {\n let key = CacheKey::new(repo_id, &format!(\"key-{}\", i), \"main\");\n let data = format!(\"value{}\", i);\n CacheStore::put(&storage, key.clone(), data.into_bytes()).await.unwrap();\n }\n\n // Get them back\n for i in 0..3 {\n let key = CacheKey::new(repo_id, &format!(\"key-{}\", i), \"main\");\n let retrieved = CacheStore::get(&storage, &key).await.unwrap();\n assert!(retrieved.is_some());\n assert_eq!(retrieved.unwrap(), format!(\"value{}\", i).into_bytes());\n }\n }\n\n #[tokio::test]\n async fn test_artifact_exactly_at_limit() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"size-test\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"xyz\".to_string(),\n size_bytes: 0,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n\n // Empty data\n ArtifactStore::put(&storage, &artifact, b\"\").await.unwrap();\n let data = ArtifactStore::get(&storage, artifact.id).await.unwrap();\n assert_eq!(data, b\"\");\n }\n\n #[tokio::test]\n async fn test_artifact_with_special_characters_in_name() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"test-artifact-with-dashes_and_underscores\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"special\".to_string(),\n size_bytes: 100,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n\n ArtifactStore::put(&storage, &artifact, b\"special content\").await.unwrap();\n let retrieved = ArtifactStore::get(&storage, artifact.id).await.unwrap();\n assert_eq!(retrieved, b\"special content\");\n }\n\n #[tokio::test]\n async fn test_cache_overwrite() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let repo_id = gitforce_common::RepoId::new();\n let key = CacheKey::new(repo_id, \"overwrite-test\", \"main\");\n\n // Put first value\n CacheStore::put(&storage, key.clone(), b\"first\".to_vec()).await.unwrap();\n let first = CacheStore::get(&storage, &key).await.unwrap();\n assert_eq!(first.unwrap(), b\"first\");\n\n // Overwrite with second value\n CacheStore::put(&storage, key.clone(), b\"second\".to_vec()).await.unwrap();\n let second = CacheStore::get(&storage, &key).await.unwrap();\n assert_eq!(second.unwrap(), b\"second\");\n }\n\n #[tokio::test]\n async fn test_artifact_list() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n // List should be empty initially\n let artifacts = ArtifactStore::list(&storage).await.unwrap();\n assert!(artifacts.is_empty());\n\n // Add an artifact\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: gitforce_common::JobId::new(),\n name: \"list-test\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 100,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact, b\"test data\").await.unwrap();\n\n // List should have one artifact\n let artifacts = ArtifactStore::list(&storage).await.unwrap();\n assert_eq!(artifacts.len(), 1);\n assert_eq!(artifacts[0].name, \"list-test\");\n }\n\n #[tokio::test]\n async fn test_artifact_list_by_job() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let job_id = gitforce_common::JobId::new();\n let other_job_id = gitforce_common::JobId::new();\n\n // Add artifact for job_id\n let artifact1 = Artifact {\n id: ArtifactId::new(),\n job_id,\n name: \"job-artifact\".to_string(),\n path: \"/fake/path1\".to_string(),\n checksum: \"abc123\".to_string(),\n size_bytes: 100,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact1, b\"data1\").await.unwrap();\n\n // Add artifact for other_job_id\n let artifact2 = Artifact {\n id: ArtifactId::new(),\n job_id: other_job_id,\n name: \"other-job-artifact\".to_string(),\n path: \"/fake/path2\".to_string(),\n checksum: \"def456\".to_string(),\n size_bytes: 200,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact2, b\"data2\").await.unwrap();\n\n // List by job_id should return only artifact1\n let artifacts = ArtifactStore::list_by_job(&storage, job_id).await.unwrap();\n assert_eq!(artifacts.len(), 1);\n assert_eq!(artifacts[0].name, \"job-artifact\");\n\n // List by other_job_id should return only artifact2\n let artifacts = ArtifactStore::list_by_job(&storage, other_job_id).await.unwrap();\n assert_eq!(artifacts.len(), 1);\n assert_eq!(artifacts[0].name, \"other-job-artifact\");\n }\n\n #[tokio::test]\n async fn test_artifact_list_by_job_none() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let job_id = gitforce_common::JobId::new();\n\n // List by job_id with no artifacts should return empty\n let artifacts = ArtifactStore::list_by_job(&storage, job_id).await.unwrap();\n assert!(artifacts.is_empty());\n }\n\n #[tokio::test]\n async fn test_artifact_list_multiple() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let job_id = gitforce_common::JobId::new();\n\n // Add multiple artifacts for same job\n for i in 0..5 {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id,\n name: format!(\"artifact-{}\", i),\n path: format!(\"/fake/path{}\", i),\n checksum: format!(\"checksum{}\", i),\n size_bytes: 100 + i as u64,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact, format!(\"data{}\", i).as_bytes()).await.unwrap();\n }\n\n let artifacts = ArtifactStore::list_by_job(&storage, job_id).await.unwrap();\n assert_eq!(artifacts.len(), 5);\n }\n\n #[tokio::test]\n async fn test_artifact_list_all_jobs() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let job1 = gitforce_common::JobId::new();\n let job2 = gitforce_common::JobId::new();\n\n // Add artifacts for different jobs\n for (i, job_id) in [job1, job1, job2].iter().enumerate() {\n let artifact = Artifact {\n id: ArtifactId::new(),\n job_id: *job_id,\n name: format!(\"artifact-{}\", i),\n path: format!(\"/fake/path{}\", i),\n checksum: format!(\"checksum{}\", i),\n size_bytes: 100,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact, format!(\"data{}\", i).as_bytes()).await.unwrap();\n }\n\n // list() should return all artifacts\n let all = ArtifactStore::list(&storage).await.unwrap();\n assert_eq!(all.len(), 3);\n\n // list_by_job should filter correctly\n let job1_artifacts = ArtifactStore::list_by_job(&storage, job1).await.unwrap();\n assert_eq!(job1_artifacts.len(), 2);\n }\n\n #[tokio::test]\n async fn test_artifact_list_empty_directory() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n // Empty artifacts directory\n let artifacts = ArtifactStore::list(&storage).await.unwrap();\n assert!(artifacts.is_empty());\n }\n\n #[tokio::test]\n async fn test_artifact_overwrite() {\n let dir = tempdir().unwrap();\n let storage = FileStorage::new(dir.path()).await.unwrap();\n\n let artifact_id = ArtifactId::new();\n let job_id = gitforce_common::JobId::new();\n\n let artifact = Artifact {\n id: artifact_id,\n job_id,\n name: \"overwrite-test\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"original\".to_string(),\n size_bytes: 100,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n\n // First write\n ArtifactStore::put(&storage, &artifact, b\"original data\").await.unwrap();\n let data1 = ArtifactStore::get(&storage, artifact_id).await.unwrap();\n assert_eq!(data1, b\"original data\");\n\n // Overwrite with same ID\n let artifact2 = Artifact {\n id: artifact_id,\n job_id,\n name: \"overwrite-test\".to_string(),\n path: \"/fake/path\".to_string(),\n checksum: \"updated\".to_string(),\n size_bytes: 200,\n content_type: None,\n created_at: chrono::Utc::now(),\n };\n ArtifactStore::put(&storage, &artifact2, b\"updated data\").await.unwrap();\n let data2 = ArtifactStore::get(&storage, artifact_id).await.unwrap();\n assert_eq!(data2, b\"updated data\");\n }\n}\n","traces":[{"line":21,"address":[11074545,11073585,11073376,11073542,11073408,11073446,11073389,11073611,11074001],"length":1,"stats":{"Line":17}},{"line":22,"address":[11073520],"length":1,"stats":{"Line":4}},{"line":23,"address":[11073655,11073733],"length":1,"stats":{"Line":8}},{"line":24,"address":[3491698,3491776],"length":1,"stats":{"Line":8}},{"line":27,"address":[11073869,11075567,11074032,11074347,11075573,11073572,11073946,11075344,11074520],"length":1,"stats":{"Line":11}},{"line":28,"address":[3493356,3493291],"length":1,"stats":{"Line":0}},{"line":31,"address":[3440814],"length":1,"stats":{"Line":5}},{"line":32,"address":[],"length":0,"stats":{"Line":0}},{"line":35,"address":[],"length":0,"stats":{"Line":3}},{"line":36,"address":[11074961],"length":1,"stats":{"Line":3}},{"line":37,"address":[11074997],"length":1,"stats":{"Line":3}},{"line":38,"address":[11075033],"length":1,"stats":{"Line":3}},{"line":43,"address":[22189504],"length":1,"stats":{"Line":1}},{"line":44,"address":[19739995],"length":1,"stats":{"Line":2}},{"line":48,"address":[18637360],"length":1,"stats":{"Line":1}},{"line":49,"address":[22189755],"length":1,"stats":{"Line":1}},{"line":53,"address":[18637121,18636816,18637127],"length":1,"stats":{"Line":1}},{"line":54,"address":[22189219],"length":1,"stats":{"Line":1}},{"line":60,"address":[3475090,3475147,3476497,3480462,3474751,3474704,3474860,3474924,3475443,3474903,3475028,3474945],"length":1,"stats":{"Line":13}},{"line":61,"address":[18651175],"length":1,"stats":{"Line":1}},{"line":62,"address":[18651333],"length":1,"stats":{"Line":1}},{"line":65,"address":[22244740],"length":1,"stats":{"Line":3}},{"line":66,"address":[18657099,18657164],"length":1,"stats":{"Line":0}},{"line":69,"address":[3480528,3474911,3477361,3480749,3476379,3476292,3476511,3476783,3480743],"length":1,"stats":{"Line":6}},{"line":70,"address":[19759643,19759708],"length":1,"stats":{"Line":0}},{"line":74,"address":[22210783,22205905,22205582,22205416,22210789,22210560],"length":1,"stats":{"Line":1}},{"line":75,"address":[19761035,19761100],"length":1,"stats":{"Line":0}},{"line":78,"address":[19760981,19753860,19756922,19760975,19756391,19756185,19757487,19760752,19756259],"length":1,"stats":{"Line":8}},{"line":79,"address":[19760779,19760844],"length":1,"stats":{"Line":0}},{"line":82,"address":[22210048,22210277,22203433,22206801,22207078,22207362,22206906,22210271],"length":1,"stats":{"Line":9}},{"line":83,"address":[18657788,18657723],"length":1,"stats":{"Line":0}},{"line":86,"address":[22207902,22207447],"length":1,"stats":{"Line":6}},{"line":91,"address":[19758314],"length":1,"stats":{"Line":3}},{"line":94,"address":[18650254,18648131,18648438,18648096,18648626,18649709,18648222,18648330,18648393,18648259],"length":1,"stats":{"Line":8}},{"line":95,"address":[18648353],"length":1,"stats":{"Line":1}},{"line":97,"address":[22244463],"length":1,"stats":{"Line":6}},{"line":98,"address":[18650347,18650412],"length":1,"stats":{"Line":2}},{"line":101,"address":[19752253],"length":1,"stats":{"Line":2}},{"line":102,"address":[19751064,19753605,19753376,19752807,19752520,19752399,19752321,19753599],"length":1,"stats":{"Line":7}},{"line":103,"address":[22203020,22202955],"length":1,"stats":{"Line":0}},{"line":106,"address":[18650113],"length":1,"stats":{"Line":2}},{"line":109,"address":[18636767],"length":1,"stats":{"Line":6}},{"line":110,"address":[3487407],"length":1,"stats":{"Line":1}},{"line":111,"address":[22216467],"length":1,"stats":{"Line":1}},{"line":113,"address":[19767087,19766999,19767758],"length":1,"stats":{"Line":3}},{"line":114,"address":[19766644,19767956,19769904,19767160,19767305,19767673,19770133,19770127],"length":1,"stats":{"Line":1}},{"line":115,"address":[22219483,22219548],"length":1,"stats":{"Line":0}},{"line":119,"address":[3488383,3489019,3487762],"length":1,"stats":{"Line":3}},{"line":120,"address":[19796677],"length":1,"stats":{"Line":1}},{"line":121,"address":[18667755,18667820],"length":1,"stats":{"Line":0}},{"line":125,"address":[19768880,19768463,19767806],"length":1,"stats":{"Line":3}},{"line":126,"address":[3489415],"length":1,"stats":{"Line":1}},{"line":129,"address":[22197187,22197315,22197682,22198765,22197278,22197449,22199611,22197386,22197494,22197152],"length":1,"stats":{"Line":8}},{"line":130,"address":[22197409],"length":1,"stats":{"Line":1}},{"line":132,"address":[3469359,3469049,3469929,3471344,3469445,3471569,3470436,3471563,3469282],"length":1,"stats":{"Line":2}},{"line":133,"address":[22200219,22200284],"length":1,"stats":{"Line":0}},{"line":136,"address":[22198509],"length":1,"stats":{"Line":1}},{"line":137,"address":[18644968,18646303,18647328,18646424,18647557,18647551,18647289,18646225,18646711],"length":1,"stats":{"Line":4}},{"line":138,"address":[3471851,3471916],"length":1,"stats":{"Line":0}},{"line":141,"address":[22199936,22199369,22200159,22199169,22199616,22200165],"length":1,"stats":{"Line":1}},{"line":142,"address":[3471611,3471672],"length":1,"stats":{"Line":0}},{"line":145,"address":[18647168],"length":1,"stats":{"Line":1}},{"line":148,"address":[19739503],"length":1,"stats":{"Line":10}},{"line":149,"address":[22211178],"length":1,"stats":{"Line":2}},{"line":151,"address":[19795652],"length":1,"stats":{"Line":5}},{"line":152,"address":[18663244,18663179],"length":1,"stats":{"Line":0}},{"line":155,"address":[18692875],"length":1,"stats":{"Line":3}},{"line":156,"address":[19766235,19766300],"length":1,"stats":{"Line":0}},{"line":158,"address":[18661750],"length":1,"stats":{"Line":1}},{"line":160,"address":[3486846,3486832,3485399],"length":1,"stats":{"Line":3}},{"line":161,"address":[3485574],"length":1,"stats":{"Line":2}},{"line":162,"address":[18662110],"length":1,"stats":{"Line":2}},{"line":163,"address":[22215084,22211059,22212241,22214638,22214488,22214565],"length":1,"stats":{"Line":9}},{"line":164,"address":[22211080,22212278,22212363,22215234],"length":1,"stats":{"Line":10}},{"line":165,"address":[22212704,22212887],"length":1,"stats":{"Line":4}},{"line":166,"address":[22212912,22213020],"length":1,"stats":{"Line":6}},{"line":174,"address":[22214128],"length":1,"stats":{"Line":1}},{"line":177,"address":[3386985],"length":1,"stats":{"Line":6}},{"line":178,"address":[18643798,18643897,18643585,18643670],"length":1,"stats":{"Line":3}},{"line":179,"address":[18644752,18644777,18644544,18644431],"length":1,"stats":{"Line":4}},{"line":185,"address":[22223216,22223531,22227230,22227524,22227364,22223263,22223405,22223448,22223671,22223645,22223890,22225055,22227136,22227162,22227457],"length":1,"stats":{"Line":5}},{"line":186,"address":[3496967],"length":1,"stats":{"Line":1}},{"line":188,"address":[19778335,19778341,19774170,19774244,19778112,19773883,19774372,19774903,19775472],"length":1,"stats":{"Line":3}},{"line":189,"address":[18675404,18675339],"length":1,"stats":{"Line":0}},{"line":192,"address":[3498365,3501137,3498207,3498090,3496800,3498637,3500912,3501131],"length":1,"stats":{"Line":3}},{"line":193,"address":[3501004,3500939],"length":1,"stats":{"Line":0}},{"line":196,"address":[3499173,3498718],"length":1,"stats":{"Line":2}},{"line":197,"address":[22225857],"length":1,"stats":{"Line":1}},{"line":200,"address":[3494428,3494041,3493811,3493902,3495455,3494010,3496016,3494086,3493776,3493939],"length":1,"stats":{"Line":8}},{"line":201,"address":[3494021],"length":1,"stats":{"Line":1}},{"line":203,"address":[22220690,22220763],"length":1,"stats":{"Line":4}},{"line":204,"address":[3494224],"length":1,"stats":{"Line":1}},{"line":207,"address":[22221535,22221031,22222058,22220875,22220489,22220921,22222960,22223189,22223183],"length":1,"stats":{"Line":5}},{"line":208,"address":[22223052,22222987],"length":1,"stats":{"Line":0}},{"line":211,"address":[3495231],"length":1,"stats":{"Line":1}},{"line":212,"address":[22221895,22220504,22222927,22221973,22222361,22222074,22222933,22222704],"length":1,"stats":{"Line":4}},{"line":213,"address":[19773179,19773244],"length":1,"stats":{"Line":0}},{"line":216,"address":[18670115],"length":1,"stats":{"Line":2}},{"line":219,"address":[22190164],"length":1,"stats":{"Line":6}},{"line":220,"address":[22229222],"length":1,"stats":{"Line":1}},{"line":222,"address":[19779852,19780464,19779779],"length":1,"stats":{"Line":3}},{"line":223,"address":[18691782],"length":1,"stats":{"Line":2}},{"line":224,"address":[18677836,18677771],"length":1,"stats":{"Line":0}},{"line":228,"address":[3502640],"length":1,"stats":{"Line":1}},{"line":231,"address":[3388297],"length":1,"stats":{"Line":6}},{"line":234,"address":[3501975,3502069],"length":1,"stats":{"Line":2}}],"covered":86,"coverable":106},{"path":["/","nas","Temp","repos","GitForge","crates","gitforce-storage","src","lib.rs"],"content":"//! GitForce Storage\n//!\n//! Artifact and cache storage.\n\npub mod artifact;\npub mod cache;\npub mod filesystem;\n\npub use artifact::{Artifact, ArtifactId, ArtifactStore};\npub use cache::{CacheKey, CacheStore};\npub use filesystem::FileStorage;\n","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","api_route_tests.rs"],"content":"//! API Route Handler Tests\n//!\n//! These tests verify API route handlers using tower's ServiceExt\n//! for oneshot requests. Tests auth, error handling, and response formats.\n\nuse gitforce_api::auth::{ApiAuth, Claims};\nuse gitforce_api::{ApiServer, Metrics};\nuse gitforce_common::{UserId, RepoId, PipelineId, PipelineRunId, JobId, RunnerId};\nuse gitforce_db::Pool;\nuse axum::{\n http::StatusCode,\n response::IntoResponse,\n routing::get,\n Router,\n};\nuse tower::util::ServiceExt;\nuse std::sync::Arc;\n\n/// Create a test router with auth and pool extensions\nfn create_test_router(pool: Pool) -> Router {\n let auth = ApiAuth::new(\"test-secret-key-for-testing\");\n let metrics = Metrics::new();\n\n Router::new()\n .route(\"/test-auth\", get(test_auth_handler))\n .route(\"/test-public\", get(test_public_handler))\n .layer(axum::extract::Extension(Arc::new(auth)))\n .layer(axum::extract::Extension(Arc::new(metrics)))\n .layer(axum::extract::Extension(Arc::new(pool)))\n}\n\nasync fn test_auth_handler(\n _auth: axum::extract::Extension>,\n _pool: axum::extract::Extension>,\n) -> &'static str {\n \"authenticated\"\n}\n\nasync fn test_public_handler() -> &'static str {\n \"public\"\n}\n\n#[tokio::test]\nasync fn test_api_auth_token_generation() {\n let auth = ApiAuth::new(\"test-secret\");\n let user_id = UserId::new();\n\n let token = auth.generate_token(user_id, \"testuser\", \"admin\").unwrap();\n assert!(!token.is_empty());\n\n // Validate token\n let claims = auth.validate_token(&token).unwrap();\n assert_eq!(claims.username, \"testuser\");\n assert_eq!(claims.role, \"admin\");\n}\n\n#[tokio::test]\nasync fn test_api_auth_token_extraction() {\n // Valid Bearer token\n assert_eq!(ApiAuth::extract_token(\"Bearer abc123\"), Some(\"abc123\"));\n\n // Invalid formats\n assert_eq!(ApiAuth::extract_token(\"Basic abc123\"), None);\n assert_eq!(ApiAuth::extract_token(\"abc123\"), None);\n assert_eq!(ApiAuth::extract_token(\"bearer abc123\"), None); // case sensitive\n}\n\n#[tokio::test]\nasync fn test_api_auth_expired_token() {\n let auth = ApiAuth::new(\"test-secret\");\n let user_id = UserId::new();\n\n // Create a token that's already expired\n let expired_claims = Claims {\n sub: user_id.to_string(),\n user_id,\n username: \"testuser\".to_string(),\n role: \"admin\".to_string(),\n exp: chrono::Utc::now().timestamp() - 3600, // expired 1 hour ago\n iat: chrono::Utc::now().timestamp() - 7200,\n };\n\n let token = jsonwebtoken::encode(\n &jsonwebtoken::Header::default(),\n &expired_claims,\n &jsonwebtoken::EncodingKey::from_secret(\"test-secret\".as_bytes()),\n ).unwrap();\n\n let result = auth.validate_token(&token);\n assert!(result.is_err());\n}\n\n#[tokio::test]\nasync fn test_api_auth_wrong_secret() {\n let auth1 = ApiAuth::new(\"secret-one\");\n let auth2 = ApiAuth::new(\"secret-two\");\n let user_id = UserId::new();\n\n let token = auth1.generate_token(user_id, \"testuser\", \"admin\").unwrap();\n let result = auth2.validate_token(&token);\n assert!(result.is_err());\n}\n\n#[tokio::test]\nasync fn test_api_auth_invalid_token() {\n let auth = ApiAuth::new(\"test-secret\");\n\n // Completely invalid token\n let result = auth.validate_token(\"not.a.valid.token\");\n assert!(result.is_err());\n\n // Malformed JWT\n let result = auth.validate_token(\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature\");\n assert!(result.is_err());\n}\n\n#[test]\nfn test_claims_creation() {\n let user_id = UserId::new();\n let claims = Claims::new(user_id, \"testuser\", \"developer\", 24);\n\n assert_eq!(claims.username, \"testuser\");\n assert_eq!(claims.role, \"developer\");\n assert_eq!(claims.user_id, user_id);\n assert!(!claims.is_expired());\n}\n\n#[test]\nfn test_claims_expiry() {\n let user_id = UserId::new();\n let mut claims = Claims::new(user_id, \"testuser\", \"admin\", 1);\n\n // Should not be expired initially\n assert!(!claims.is_expired());\n\n // Manually set to expired\n claims.exp = chrono::Utc::now().timestamp() - 1;\n assert!(claims.is_expired());\n}\n\n#[test]\nfn test_claims_future_expiry() {\n let user_id = UserId::new();\n // Create claims with 100 year expiry\n let claims = Claims::new(user_id, \"testuser\", \"admin\", 24 * 365 * 100);\n assert!(!claims.is_expired());\n}\n\n/// Test error response creation\n#[test]\nfn test_error_response_creation() {\n use gitforce_api::server::error_response;\n\n let response = error_response(StatusCode::NOT_FOUND, \"not_found\", \"Resource not found\");\n let response = response.into_response();\n\n assert_eq!(response.status(), StatusCode::NOT_FOUND);\n}\n\n/// Test API server creation\n#[tokio::test]\nasync fn test_api_server_creation() {\n let pool = Pool::memory().await.unwrap();\n let server = ApiServer::new(\"test-secret\", pool);\n\n // Just verify it doesn't panic - port is private so we can't check value\n let _ = server;\n}\n\n/// Test UUID parsing for API routes\n#[test]\nfn test_uuid_parsing() {\n use uuid::Uuid;\n\n // Valid UUID\n let valid = \"550e8400-e29b-41d4-a716-446655440000\";\n assert!(Uuid::parse_str(valid).is_ok());\n\n // Invalid UUID\n let invalid = \"not-a-uuid\";\n assert!(Uuid::parse_str(invalid).is_err());\n\n // Empty string\n assert!(Uuid::parse_str(\"\").is_err());\n}\n\n/// Test repo ID conversion\n#[test]\nfn test_repo_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let repo_id = RepoId::from(uuid);\n let back = uuid::Uuid::from(repo_id);\n assert_eq!(uuid, back);\n}\n\n/// Test user ID conversion\n#[test]\nfn test_user_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let user_id = UserId::from(uuid);\n let back = uuid::Uuid::from(user_id);\n assert_eq!(uuid, back);\n}\n\n/// Test pipeline ID conversion\n#[test]\nfn test_pipeline_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let pipeline_id = PipelineId::from(uuid);\n let back = uuid::Uuid::from(pipeline_id);\n assert_eq!(uuid, back);\n}\n\n/// Test pipeline run ID conversion\n#[test]\nfn test_pipeline_run_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let run_id = PipelineRunId::from(uuid);\n let back = uuid::Uuid::from(run_id);\n assert_eq!(uuid, back);\n}\n\n/// Test job ID conversion\n#[test]\nfn test_job_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let job_id = JobId::from(uuid);\n let back = uuid::Uuid::from(job_id);\n assert_eq!(uuid, back);\n}\n\n/// Test runner ID conversion\n#[test]\nfn test_runner_id_conversion() {\n let uuid = uuid::Uuid::new_v4();\n let runner_id = RunnerId::from(uuid);\n let back = uuid::Uuid::from(runner_id);\n assert_eq!(uuid, back);\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","ci_integration_tests.rs"],"content":"//! Integration tests for CI Engine\n//!\n//! These tests verify the CI engine's pipeline orchestration,\n//! DAG building, job state management, and pipeline lifecycle.\n\nuse gitforce_common::{\n JobId, JobStatus, PipelineId, PipelineRunId, PipelineStatus, RepoId, RunnerId,\n};\nuse gitforce_ci::{\n CiEngine, DagBuilder, JobGraph, JobNode, JobStateMachine, PipelineDefinition,\n PipelineExecutor, PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition,\n};\nuse std::collections::HashMap;\n\n/// Create a simple pipeline definition with two sequential jobs\nfn create_sequential_pipeline() -> PipelineDefinition {\n PipelineDefinition {\n name: \"sequential-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n }\n}\n\n/// Create a parallel pipeline where build and lint can run concurrently\nfn create_parallel_pipeline() -> PipelineDefinition {\n PipelineDefinition {\n name: \"parallel-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"lint\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"lint\".to_string(),\n run: \"cargo clippy\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n }\n}\n\n#[tokio::test]\nasync fn test_ci_engine_initialization() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let state = engine.state().await;\n\n assert_eq!(state.status, PipelineStatus::Pending);\n assert!(state.started_at.is_none());\n assert!(state.finished_at.is_none());\n assert_eq!(state.jobs.len(), 2); // build and test\n}\n\n#[tokio::test]\nasync fn test_ci_engine_start() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Running);\n assert!(state.started_at.is_some());\n}\n\n#[tokio::test]\nasync fn test_ci_engine_ready_jobs_after_start() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n // Only \"build\" should be ready initially (no dependencies)\n assert_eq!(ready.len(), 1);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_job_assignment() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n assert_eq!(ready.len(), 1);\n\n let build_job = ready[0];\n let runner_id = RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n\n let job_state = engine.get_job(build_job).await;\n assert!(job_state.is_some());\n assert_eq!(job_state.unwrap().runner_id(), Some(runner_id));\n}\n\n#[tokio::test]\nasync fn test_ci_engine_job_start() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n let runner_id = RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n\n let job_state = engine.get_job(build_job).await;\n assert_eq!(job_state.unwrap().status(), JobStatus::Running);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_job_success() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n let runner_id = RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.succeed_job(build_job, 0).await.unwrap();\n\n let job_state = engine.get_job(build_job).await;\n assert_eq!(job_state.unwrap().status(), JobStatus::Succeeded);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_pipeline_completion() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n // Build job\n let ready = engine.ready_jobs().await;\n let build_job = ready[0];\n let runner_id = RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.succeed_job(build_job, 0).await.unwrap();\n\n // After build succeeds, test job should become ready\n let ready = engine.ready_jobs().await;\n // Note: The engine doesn't automatically transition jobs,\n // they need to be picked up by scheduler\n}\n\n#[tokio::test]\nasync fn test_ci_engine_cancel() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n engine.start().await.unwrap();\n\n engine.cancel().await.unwrap();\n\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Cancelled);\n assert!(state.finished_at.is_some());\n}\n\n#[tokio::test]\nasync fn test_ci_engine_graph_access() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let graph = engine.graph();\n\n assert_eq!(graph.nodes.len(), 2);\n assert_eq!(graph.entry_points().len(), 1); // \"build\" has no deps\n}\n\n/// DAG tests\n#[test]\nfn test_dag_builder_simple() {\n let pipeline = PipelineDefinition {\n name: \"test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n };\n\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n assert_eq!(graph.nodes.len(), 2);\n assert!(!graph.has_cycle());\n\n let build = graph.get_by_name(\"build\").unwrap();\n assert!(build.dependencies.is_empty());\n\n let test = graph.get_by_name(\"test\").unwrap();\n assert_eq!(test.dependencies.len(), 1);\n}\n\n#[test]\nfn test_dag_builder_parallel_jobs() {\n let pipeline = create_parallel_pipeline();\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n assert_eq!(graph.nodes.len(), 3);\n assert!(!graph.has_cycle());\n\n // build and lint should be entry points (no dependencies)\n let entry_points = graph.entry_points();\n assert_eq!(entry_points.len(), 2);\n}\n\n#[test]\nfn test_dag_topological_order() {\n let pipeline = create_sequential_pipeline();\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n let order = graph.topological_order();\n assert_eq!(order.len(), 2);\n\n // Build should come before test\n let build_idx = order.iter().position(|id| graph.get(*id).unwrap().name == \"build\").unwrap();\n let test_idx = order.iter().position(|id| graph.get(*id).unwrap().name == \"test\").unwrap();\n assert!(build_idx < test_idx);\n}\n\n#[test]\nfn test_dag_cycle_detection() {\n // Create a pipeline with a cycle: build -> test -> build\n let pipeline = PipelineDefinition {\n name: \"cyclic\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"a\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"c\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo a\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"b\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"a\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo b\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"c\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"b\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo c\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n };\n\n let run_id = PipelineRunId::new();\n let result = DagBuilder::build(&pipeline, run_id);\n assert!(result.is_err());\n}\n\n#[test]\nfn test_dag_get_node() {\n let pipeline = create_sequential_pipeline();\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n // Get node by name\n let build = graph.get_by_name(\"build\");\n assert!(build.is_some());\n assert_eq!(build.unwrap().name, \"build\");\n\n // Get node by ID\n let build_id = build.unwrap().id;\n let by_id = graph.get(build_id);\n assert!(by_id.is_some());\n assert_eq!(by_id.unwrap().name, \"build\");\n\n // Non-existent node\n let nonexistent = graph.get_by_name(\"nonexistent\");\n assert!(nonexistent.is_none());\n}\n\n#[test]\nfn test_dag_dependents() {\n let pipeline = create_sequential_pipeline();\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n\n // Build has \"test\" as dependent\n let build_id = graph.get_by_name(\"build\").unwrap().id;\n let dependents = graph.dependents(build_id);\n assert_eq!(dependents.len(), 1);\n assert_eq!(dependents[0].name, \"test\");\n\n // Test has no dependents\n let test_id = graph.get_by_name(\"test\").unwrap().id;\n let dependents = graph.dependents(test_id);\n assert!(dependents.is_empty());\n}\n\n#[test]\nfn test_dag_max_depth() {\n // Pipeline: build -> test -> deploy (depth 2)\n let pipeline = PipelineDefinition {\n name: \"depth-test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"deploy\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"test\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"step\".to_string(),\n run: \"echo\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n };\n\n let run_id = PipelineRunId::new();\n let graph = DagBuilder::build(&pipeline, run_id).unwrap();\n let depth = DagBuilder::max_depth(&graph);\n assert_eq!(depth, 2); // build=0, test=1, deploy=2\n}\n\n/// Job state machine tests\n#[test]\nfn test_job_state_pending() {\n let job_id = JobId::new();\n let state = JobStateMachine::new(job_id);\n\n assert_eq!(state.status(), JobStatus::Pending);\n assert!(state.runner_id().is_none());\n assert!(state.exit_code().is_none());\n assert!(state.error_message().is_none());\n}\n\n#[test]\nfn test_job_state_full_lifecycle() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n // Pending -> Queued\n state.queue().unwrap();\n assert_eq!(state.status(), JobStatus::Queued);\n\n // Queued -> Assigned\n let runner_id = RunnerId::new();\n state.assign(runner_id).unwrap();\n assert_eq!(state.status(), JobStatus::Assigned);\n assert_eq!(state.runner_id(), Some(runner_id));\n\n // Assigned -> Running\n state.start().unwrap();\n assert_eq!(state.status(), JobStatus::Running);\n\n // Running -> Succeeded\n state.succeed(0).unwrap();\n assert_eq!(state.status(), JobStatus::Succeeded);\n assert_eq!(state.exit_code(), Some(0));\n assert!(state.is_terminal());\n}\n\n#[test]\nfn test_job_state_failure() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(RunnerId::new()).unwrap();\n state.start().unwrap();\n state.fail(1, \"compilation error\".to_string()).unwrap();\n\n assert_eq!(state.status(), JobStatus::Failed);\n assert_eq!(state.exit_code(), Some(1));\n assert_eq!(state.error_message(), Some(\"compilation error\"));\n assert!(state.is_terminal());\n}\n\n#[test]\nfn test_job_state_cancel() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.cancel().unwrap();\n\n assert_eq!(state.status(), JobStatus::Cancelled);\n assert!(state.is_terminal());\n}\n\n#[test]\nfn test_job_state_invalid_transitions() {\n let job_id = JobId::new();\n let mut state = JobStateMachine::new(job_id);\n\n // Can't go directly from Pending to Running\n assert!(state.start().is_err());\n\n // Can't go from Pending to Succeeded\n assert!(state.succeed(0).is_err());\n\n // Can't fail a pending job\n assert!(state.fail(1, \"error\".to_string()).is_err());\n}\n\n#[test]\nfn test_job_state_summary() {\n let job_id = JobId::new();\n let runner_id = RunnerId::new();\n let mut state = JobStateMachine::new(job_id);\n\n state.queue().unwrap();\n state.assign(runner_id).unwrap();\n state.start().unwrap();\n state.succeed(0).unwrap();\n\n let summary = state.summary();\n assert_eq!(summary.job_id, job_id);\n assert_eq!(summary.status, JobStatus::Succeeded);\n assert_eq!(summary.runner_id, Some(runner_id));\n assert_eq!(summary.exit_code, Some(0));\n}\n\n/// Pipeline executor tests\n#[tokio::test]\nasync fn test_pipeline_executor_creation() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let executor = PipelineExecutor::new(engine);\n\n let state = executor.state().await;\n assert_eq!(state.status, PipelineStatus::Pending);\n}\n\n#[tokio::test]\nasync fn test_pipeline_executor_start() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let executor = PipelineExecutor::new(engine);\n\n executor.start().await.unwrap();\n\n let state = executor.state().await;\n assert_eq!(state.status, PipelineStatus::Running);\n}\n\n#[tokio::test]\nasync fn test_pipeline_executor_ready_jobs() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let executor = PipelineExecutor::new(engine);\n\n executor.start().await.unwrap();\n let ready = executor.ready_jobs().await;\n\n assert_eq!(ready.len(), 1);\n}\n\n#[tokio::test]\nasync fn test_pipeline_executor_graph() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n let pipeline = create_sequential_pipeline();\n\n let engine = CiEngine::new(event, pipeline).await.unwrap();\n let executor = PipelineExecutor::new(engine);\n\n let graph = executor.graph().await;\n assert_eq!(graph.nodes.len(), 2);\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","database_integration_tests.rs"],"content":"//! Integration tests for Database Queries\n//!\n//! These tests verify database query functions with in-memory SQLite:\n//! - Repository CRUD operations\n//! - Pipeline and PipelineRun queries\n//! - Job assignment and status updates\n//! - Runner management\n\nuse gitforce_common::{JobId, PipelineId, PipelineRunId, RepoId, RunnerId, UserId};\nuse gitforce_db::models::{Job, Pipeline, PipelineRun, Repository, Runner, RunnerType, User};\nuse gitforce_db::queries::{JobQueries, PipelineQueries, PipelineRunQueries, RepoQueries, RunnerQueries, UserQueries};\nuse gitforce_db::Pool;\nuse crate::integration_test_helpers::*;\n\n/// Test repository creation, retrieval, listing, and deletion\n#[tokio::test]\nasync fn test_repo_crud_operations() {\n let pool = create_test_db_pool().await;\n\n // Create user first (repo has FK to owner)\n let user = create_test_user(\"repo_owner\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n // Create repository\n let repo = create_test_repo(\"test-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // Get repository\n let found = RepoQueries::get(&pool, repo.id).await.unwrap();\n assert!(found.is_some());\n let found = found.unwrap();\n assert_eq!(found.name, \"test-repo\");\n assert_eq!(found.owner_id, user.id);\n\n // List by owner\n let repos = RepoQueries::list_by_owner(&pool, user.id).await.unwrap();\n assert_eq!(repos.len(), 1);\n\n // List all\n let all_repos = RepoQueries::list(&pool).await.unwrap();\n assert_eq!(all_repos.len(), 1);\n\n // Delete repository\n RepoQueries::delete(&pool, repo.id).await.unwrap();\n let found = RepoQueries::get(&pool, repo.id).await.unwrap();\n assert!(found.is_none());\n}\n\n/// Test repository not found case\n#[tokio::test]\nasync fn test_repo_not_found() {\n let pool = create_test_db_pool().await;\n\n let non_existent_id = RepoId::new();\n let found = RepoQueries::get(&pool, non_existent_id).await.unwrap();\n assert!(found.is_none());\n}\n\n/// Test user creation and retrieval\n#[tokio::test]\nasync fn test_user_crud_operations() {\n let pool = create_test_db_pool().await;\n\n // Create user\n let user = create_test_user(\"testuser\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n // Get by ID\n let found = UserQueries::get(&pool, user.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().username, \"testuser\");\n\n // Get by username\n let found = UserQueries::get_by_username(&pool, \"testuser\").await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().email, \"testuser@example.com\");\n\n // Not found case\n let found = UserQueries::get_by_username(&pool, \"nonexistent\").await.unwrap();\n assert!(found.is_none());\n\n // List all\n let all_users = UserQueries::list(&pool).await.unwrap();\n assert_eq!(all_users.len(), 1);\n}\n\n/// Test pipeline creation and retrieval\n#[tokio::test]\nasync fn test_pipeline_crud_operations() {\n let pool = create_test_db_pool().await;\n\n // Create user and repo first\n let user = create_test_user(\"pipeline_owner\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = create_test_repo(\"pipeline-test-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // Create pipeline\n let pipeline = create_test_pipeline(repo.id);\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n // Get pipeline\n let found = PipelineQueries::get(&pool, pipeline.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"Test Pipeline\");\n\n // List by repo\n let pipelines = PipelineQueries::list_by_repo(&pool, repo.id).await.unwrap();\n assert_eq!(pipelines.len(), 1);\n\n // List all\n let all_pipelines = PipelineQueries::list(&pool).await.unwrap();\n assert_eq!(all_pipelines.len(), 1);\n}\n\n/// Test pipeline run operations\n#[tokio::test]\nasync fn test_pipeline_run_operations() {\n let pool = create_test_db_pool().await;\n\n // Create user and repo first\n let user = create_test_user(\"run_owner\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = create_test_repo(\"run-test-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = create_test_pipeline(repo.id);\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n // Create pipeline run\n let run = create_test_pipeline_run(pipeline.id, repo.id);\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Get pipeline run\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().commit_hash, \"abc123def456\");\n\n // Update status\n PipelineRunQueries::update_status(&pool, run.id, \"running\").await.unwrap();\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"running\");\n\n // List by pipeline\n let runs = PipelineRunQueries::list_by_pipeline(&pool, pipeline.id).await.unwrap();\n assert_eq!(runs.len(), 1);\n\n // List all\n let all_runs = PipelineRunQueries::list(&pool).await.unwrap();\n assert_eq!(all_runs.len(), 1);\n}\n\n/// Test job operations\n#[tokio::test]\nasync fn test_job_operations() {\n let pool = create_test_db_pool().await;\n\n // Create user, repo, pipeline, run first\n let user = create_test_user(\"job_owner\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = create_test_repo(\"job-test-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = create_test_pipeline(repo.id);\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = create_test_pipeline_run(pipeline.id, repo.id);\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Create job\n let job = Job::new(run.id, \"build\".to_string());\n JobQueries::create(&pool, &job).await.unwrap();\n\n // Get job\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"build\");\n\n // Update status\n JobQueries::update_status(&pool, job.id, \"running\").await.unwrap();\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"running\");\n\n // Assign runner\n let runner = create_test_runner(\"job-runner\", RunnerType::Docker);\n RunnerQueries::create(&pool, &runner).await.unwrap();\n JobQueries::assign(&pool, job.id, runner.id).await.unwrap();\n\n // List by run\n let jobs = JobQueries::list_by_run(&pool, run.id).await.unwrap();\n assert_eq!(jobs.len(), 1);\n}\n\n/// Test pending jobs listing\n#[tokio::test]\nasync fn test_pending_jobs() {\n let pool = create_test_db_pool().await;\n\n // Create user, repo, pipeline, run first\n let user = create_test_user(\"pending_job_owner\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = create_test_repo(\"pending-job-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n let pipeline = create_test_pipeline(repo.id);\n PipelineQueries::create(&pool, &pipeline).await.unwrap();\n\n let run = create_test_pipeline_run(pipeline.id, repo.id);\n PipelineRunQueries::create(&pool, &run).await.unwrap();\n\n // Create multiple jobs\n let job1 = Job::new(run.id, \"job1\".to_string());\n let job2 = Job::new(run.id, \"job2\".to_string());\n JobQueries::create(&pool, &job1).await.unwrap();\n JobQueries::create(&pool, &job2).await.unwrap();\n\n // List pending (they're created with 'pending' status)\n let pending = JobQueries::list_pending(&pool).await.unwrap();\n assert!(pending.len() >= 2);\n}\n\n/// Test runner operations\n#[tokio::test]\nasync fn test_runner_operations() {\n let pool = create_test_db_pool().await;\n\n // Create runner\n let runner = create_test_runner(\"test-runner\", RunnerType::Docker);\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // Get runner\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, \"test-runner\");\n\n // List runners\n let runners = RunnerQueries::list(&pool).await.unwrap();\n assert_eq!(runners.len(), 1);\n\n // Heartbeat\n RunnerQueries::heartbeat(&pool, runner.id).await.unwrap();\n\n // Update status\n RunnerQueries::update_status(&pool, runner.id, \"offline\").await.unwrap();\n let found = RunnerQueries::get(&pool, runner.id).await.unwrap();\n assert_eq!(found.unwrap().status, \"offline\");\n}\n\n/// Test runner online listing\n#[tokio::test]\nasync fn test_runner_online_listing() {\n let pool = create_test_db_pool().await;\n\n // Create multiple runners with different statuses\n let runner1 = create_test_runner(\"runner1\", RunnerType::Docker);\n let runner2 = create_test_runner(\"runner2\", RunnerType::Firecracker);\n RunnerQueries::create(&pool, &runner1).await.unwrap();\n RunnerQueries::create(&pool, &runner2).await.unwrap();\n\n // List online (they start as online)\n let online = RunnerQueries::list_online(&pool).await.unwrap();\n assert_eq!(online.len(), 2);\n\n // Set one offline\n RunnerQueries::update_status(&pool, runner1.id, \"offline\").await.unwrap();\n\n let online = RunnerQueries::list_online(&pool).await.unwrap();\n assert_eq!(online.len(), 1);\n}\n\n/// Test event creation and listing\n#[tokio::test]\nasync fn test_event_operations() {\n use gitforce_db::models::Event;\n use gitforce_db::queries::EventQueries;\n\n let pool = create_test_db_pool().await;\n\n // Create event\n let event = Event::new(\n \"push.received\".to_string(),\n serde_json::json!({\"repo\": \"test\", \"branch\": \"main\"}),\n );\n EventQueries::create(&pool, &event).await.unwrap();\n\n // List by type\n let events = EventQueries::list_by_type(&pool, \"push.received\", 10).await.unwrap();\n assert_eq!(events.len(), 1);\n assert_eq!(events[0].event_type, \"push.received\");\n\n // List recent\n let recent = EventQueries::list_recent(&pool, 10).await.unwrap();\n assert_eq!(recent.len(), 1);\n\n // List with limit\n let limited = EventQueries::list_by_type(&pool, \"push.received\", 1).await.unwrap();\n assert_eq!(limited.len(), 1);\n}\n\n/// Test multiple pipelines for same repo\n#[tokio::test]\nasync fn test_multiple_pipelines_per_repo() {\n let pool = create_test_db_pool().await;\n\n // Create user and repo first\n let user = create_test_user(\"multi_pipeline_user\");\n UserQueries::create(&pool, &user).await.unwrap();\n\n let repo = create_test_repo(\"multi-pipeline-repo\", user.id);\n RepoQueries::create(&pool, &repo).await.unwrap();\n\n // Create multiple pipelines\n let pipeline1 = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"CI Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n let pipeline2 = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"CD Pipeline\".to_string(),\n trigger_type: \"tag\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n\n PipelineQueries::create(&pool, &pipeline1).await.unwrap();\n PipelineQueries::create(&pool, &pipeline2).await.unwrap();\n\n // List by repo\n let pipelines = PipelineQueries::list_by_repo(&pool, repo.id).await.unwrap();\n assert_eq!(pipelines.len(), 2);\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","e2e_pipeline_tests.rs"],"content":"//! End-to-End Pipeline Flow Tests\n//!\n//! These tests verify complete flows across multiple components:\n//! - Database -> Scheduler -> CI Engine integration\n//! - Pipeline from trigger to completion\n\nuse gitforce_ci::{\n CiEngine, DagBuilder, JobGraph, PipelineDefinition, PipelineExecutor,\n PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition,\n};\nuse gitforce_common::{\n JobId, JobStatus, PipelineId, PipelineRunId, PipelineStatus, RepoId, RunnerId, UserId,\n};\nuse gitforce_db::models::{Job, Pipeline, PipelineRun, Repository, Runner, RunnerType, User};\nuse gitforce_db::queries::{\n JobQueries, PipelineQueries, PipelineRunQueries, RepoQueries, RunnerQueries, UserQueries,\n};\nuse gitforce_db::Pool;\nuse gitforce_scheduler::{Scheduler, Priority};\nuse std::collections::HashMap;\n\n/// Create a complete test database with all entities\nasync fn setup_complete_pipeline_db(pool: &Pool) -> (User, Repository, Pipeline, PipelineRun, Vec) {\n // Create user\n let user = User::new(\n \"test_user\".to_string(),\n \"test@example.com\".to_string(),\n \"hash_placeholder\".to_string(),\n );\n UserQueries::create(pool, &user).await.unwrap();\n\n // Create repository\n let repo = Repository::new(\n \"test-repo\".to_string(),\n user.id,\n \"/git/repos/test-repo\".to_string(),\n );\n RepoQueries::create(pool, &repo).await.unwrap();\n\n // Create pipeline\n let pipeline = Pipeline {\n id: PipelineId::new(),\n repo_id: repo.id,\n name: \"CI Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n };\n PipelineQueries::create(pool, &pipeline).await.unwrap();\n\n // Create pipeline run\n let run = PipelineRun::new(\n pipeline.id,\n repo.id,\n user.username.clone(),\n \"abc123def456\".to_string(),\n );\n PipelineRunQueries::create(pool, &run).await.unwrap();\n\n // Create jobs for the pipeline\n let jobs = vec![\n Job::new(run.id, \"build\".to_string()),\n Job::new(run.id, \"test\".to_string()),\n Job::new(run.id, \"lint\".to_string()),\n ];\n\n for job in &jobs {\n JobQueries::create(pool, job).await.unwrap();\n }\n\n (user, repo, pipeline, run, jobs)\n}\n\n#[tokio::test]\nasync fn test_full_pipeline_db_flow() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let (user, repo, pipeline, run, jobs) = setup_complete_pipeline_db(&pool).await;\n\n // Verify all entities were created\n assert_eq!(user.username, \"test_user\");\n assert_eq!(repo.name, \"test-repo\");\n assert_eq!(pipeline.name, \"CI Pipeline\");\n assert_eq!(run.commit_hash, \"abc123def456\");\n assert_eq!(jobs.len(), 3);\n\n // Verify jobs can be retrieved\n for job in &jobs {\n let found = JobQueries::get(&pool, job.id).await.unwrap();\n assert!(found.is_some());\n assert_eq!(found.unwrap().name, job.name);\n }\n}\n\n#[tokio::test]\nasync fn test_pipeline_run_status_updates() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let (_, _, _, run, _) = setup_complete_pipeline_db(&pool).await;\n\n // Initially pending\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap().unwrap();\n assert_eq!(found.status, \"pending\");\n\n // Update to running\n PipelineRunQueries::update_status(&pool, run.id, \"running\").await.unwrap();\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap().unwrap();\n assert_eq!(found.status, \"running\");\n\n // Update to success\n PipelineRunQueries::update_status(&pool, run.id, \"success\").await.unwrap();\n let found = PipelineRunQueries::get(&pool, run.id).await.unwrap().unwrap();\n assert_eq!(found.status, \"success\");\n}\n\n#[tokio::test]\nasync fn test_job_assignment_flow() {\n let pool = Pool::memory().await.unwrap();\n pool.migrate().await.unwrap();\n\n let (_, _, _, _, jobs) = setup_complete_pipeline_db(&pool).await;\n\n // Create a runner\n let runner = Runner::new(\"test-runner\".to_string(), RunnerType::Docker, 2);\n RunnerQueries::create(&pool, &runner).await.unwrap();\n\n // Assign job to runner\n let job = &jobs[0];\n JobQueries::assign(&pool, job.id, runner.id).await.unwrap();\n\n // Verify assignment - note: assign only sets runner_id, not status\n let found = JobQueries::get(&pool, job.id).await.unwrap().unwrap();\n assert_eq!(found.runner_id, Some(runner.id));\n // Status remains \"pending\" since assign doesn't update status\n assert_eq!(found.status, \"pending\");\n}\n\n#[tokio::test]\nasync fn test_scheduler_with_runner_registration() {\n let scheduler = Scheduler::new();\n\n // Register multiple runners\n let runner1 = Runner::new(\"runner-1\".to_string(), RunnerType::Docker, 4);\n let runner2 = Runner::new(\"runner-2\".to_string(), RunnerType::Firecracker, 2);\n\n scheduler.register_runner(runner1.clone()).await;\n scheduler.register_runner(runner2.clone()).await;\n\n // Enqueue jobs\n let job_id1 = JobId::new();\n let job_id2 = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue_with_priority(job_id1, run_id, repo_id, Priority::High).await;\n scheduler.enqueue(job_id2, run_id, repo_id).await;\n\n assert_eq!(scheduler.queue_len().await, 2);\n\n // Process queue (should assign to runner with most capacity)\n scheduler.process_queue().await;\n\n // One job should be assigned\n let assigned = scheduler.is_assigned(job_id1).await;\n assert!(assigned.is_some());\n}\n\n#[tokio::test]\nasync fn test_scheduler_job_cancel() {\n let scheduler = Scheduler::new();\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n\n scheduler.cancel(job_id).await;\n assert_eq!(scheduler.queue_len().await, 0);\n}\n\n#[tokio::test]\nasync fn test_scheduler_runner_offline() {\n let scheduler = Scheduler::new();\n\n let runner = Runner::new(\"offline-test\".to_string(), RunnerType::Docker, 2);\n scheduler.register_runner(runner.clone()).await;\n\n let runner_id = runner.id;\n scheduler.runner_offline(runner_id).await;\n\n // Verify runner is offline by checking process queue\n let job_id = JobId::new();\n scheduler.enqueue(job_id, PipelineRunId::new(), RepoId::new()).await;\n scheduler.process_queue().await;\n\n // Job should not be assigned since runner is offline\n let assigned = scheduler.is_assigned(job_id).await;\n assert!(assigned.is_none());\n}\n\n#[tokio::test]\nasync fn test_ci_engine_pipeline_parsing() {\n let yaml = r#\"\nname: test-pipeline\nversion: \"1.0\"\ntrigger_on:\n - push\n - pull_request\nenvironment:\n RUST_BACKTRACE: \"1\"\njobs:\n - name: build\n image: rust:latest\n steps:\n - name: build\n run: cargo build --release\n - name: test\n needs:\n - build\n image: rust:latest\n steps:\n - name: test\n run: cargo test\n\"#;\n\n let pipeline = PipelineDefinition::parse(yaml).unwrap();\n assert_eq!(pipeline.name, \"test-pipeline\");\n assert_eq!(pipeline.version, \"1.0\");\n assert_eq!(pipeline.trigger_on.len(), 2);\n assert_eq!(pipeline.jobs.len(), 2);\n assert_eq!(pipeline.jobs[0].name, \"build\");\n assert_eq!(pipeline.jobs[1].name, \"test\");\n assert_eq!(pipeline.jobs[1].needs, vec![\"build\"]);\n}\n\n#[tokio::test]\nasync fn test_ci_engine_pipeline_to_yaml() {\n let pipeline = PipelineDefinition {\n name: \"yaml-test\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Manual],\n environment: HashMap::new(),\n jobs: vec![JobDefinition {\n name: \"deploy\".to_string(),\n image: \"docker:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"deploy\".to_string(),\n run: \"kubectl apply\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: Some(\"30m\".to_string()),\n retry: Some(2),\n }],\n };\n\n let yaml = pipeline.to_yaml().unwrap();\n assert!(yaml.contains(\"yaml-test\"));\n assert!(yaml.contains(\"deploy\"));\n}\n\n#[tokio::test]\nasync fn test_pipeline_trigger_event_creation() {\n let pipeline_id = PipelineId::new();\n let repo_id = RepoId::new();\n\n let event = PipelineTriggerEvent::new(\n pipeline_id,\n repo_id,\n \"def456\".to_string(),\n TriggerType::Tag,\n );\n\n assert_eq!(event.pipeline_id, pipeline_id);\n assert_eq!(event.repo_id, repo_id);\n assert_eq!(event.commit_hash, \"def456\");\n assert_eq!(event.trigger_type, TriggerType::Tag);\n assert!(event.ref_name.is_none());\n assert!(event.actor_id.is_none());\n}\n\n#[tokio::test]\nasync fn test_pipeline_trigger_event_with_options() {\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n )\n .with_ref(\"refs/heads/main\".to_string())\n .with_actor(UserId::new());\n\n assert!(event.ref_name.is_some());\n assert_eq!(event.ref_name.unwrap(), \"refs/heads/main\");\n assert!(event.actor_id.is_some());\n}\n\n#[test]\nfn test_trigger_type_serialization() {\n assert_eq!(TriggerType::Push.as_str(), \"push\");\n assert_eq!(TriggerType::Tag.as_str(), \"tag\");\n assert_eq!(TriggerType::PullRequest.as_str(), \"pull_request\");\n assert_eq!(TriggerType::Manual.as_str(), \"manual\");\n}\n\n#[test]\nfn test_job_definition_has_dependencies() {\n let job_no_deps = JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![],\n timeout: None,\n retry: None,\n };\n assert!(!job_no_deps.has_dependencies());\n\n let job_with_deps = JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![],\n timeout: None,\n retry: None,\n };\n assert!(job_with_deps.has_dependencies());\n}\n\n#[test]\nfn test_step_definition_env() {\n let step_without_env = StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n };\n assert!(step_without_env.get_env().is_empty());\n\n let mut custom_env = HashMap::new();\n custom_env.insert(\"CI\".to_string(), \"true\".to_string());\n\n let step_with_env = StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: Some(custom_env),\n working_directory: None,\n condition: None,\n };\n assert_eq!(step_with_env.get_env().get(\"CI\"), Some(&\"true\".to_string()));\n}\n\n/// End-to-end test: Create pipeline, run through CI engine, complete jobs\n#[tokio::test]\nasync fn test_end_to_end_pipeline_execution() {\n // 1. Create pipeline definition\n let pipeline_def = PipelineDefinition {\n name: \"e2e-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n };\n\n // 2. Create trigger event\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n // 3. Create CI engine\n let engine = CiEngine::new(event, pipeline_def).await.unwrap();\n\n // 4. Start pipeline\n engine.start().await.unwrap();\n {\n let state = engine.state().await;\n assert_eq!(state.status, PipelineStatus::Running);\n }\n\n // 5. Get ready jobs (build should be ready)\n let ready = engine.ready_jobs().await;\n assert_eq!(ready.len(), 1);\n\n // 6. Simulate build job execution\n let build_job = ready[0];\n let runner_id = RunnerId::new();\n\n engine.assign_job(build_job, runner_id).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n engine.succeed_job(build_job, 0).await.unwrap();\n\n // 7. Verify build is done\n let job_state = engine.get_job(build_job).await.unwrap();\n assert_eq!(job_state.status(), JobStatus::Succeeded);\n\n // 8. Test job should now be ready (but engine doesn't auto-transition)\n // In real system, scheduler would pick it up\n}\n\n/// Test concurrent job execution simulation\n#[tokio::test]\nasync fn test_concurrent_jobs_in_pipeline() {\n let pipeline_def = PipelineDefinition {\n name: \"concurrent-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"lint\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"lint\".to_string(),\n run: \"cargo clippy\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"format\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"format\".to_string(),\n run: \"cargo fmt\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n };\n\n let event = PipelineTriggerEvent::new(\n PipelineId::new(),\n RepoId::new(),\n \"abc123\".to_string(),\n TriggerType::Push,\n );\n\n let engine = CiEngine::new(event, pipeline_def).await.unwrap();\n engine.start().await.unwrap();\n\n // All three jobs should be ready (no dependencies between them)\n let ready = engine.ready_jobs().await;\n assert_eq!(ready.len(), 3);\n\n // Simulate running all three concurrently\n let runner1 = RunnerId::new();\n let runner2 = RunnerId::new();\n let runner3 = RunnerId::new();\n\n let lint_job = ready.iter().find(|j| engine.graph().get(**j).unwrap().name == \"lint\").copied().unwrap();\n let format_job = ready.iter().find(|j| engine.graph().get(**j).unwrap().name == \"format\").copied().unwrap();\n let build_job = ready.iter().find(|j| engine.graph().get(**j).unwrap().name == \"build\").copied().unwrap();\n\n // Assign and start all\n engine.assign_job(lint_job, runner1).await.unwrap();\n engine.assign_job(format_job, runner2).await.unwrap();\n engine.assign_job(build_job, runner3).await.unwrap();\n\n engine.start_job(lint_job).await.unwrap();\n engine.start_job(format_job).await.unwrap();\n engine.start_job(build_job).await.unwrap();\n\n // Complete them\n engine.succeed_job(lint_job, 0).await.unwrap();\n engine.succeed_job(format_job, 0).await.unwrap();\n engine.succeed_job(build_job, 0).await.unwrap();\n\n // All should be succeeded\n assert_eq!(engine.get_job(lint_job).await.unwrap().status(), JobStatus::Succeeded);\n assert_eq!(engine.get_job(format_job).await.unwrap().status(), JobStatus::Succeeded);\n assert_eq!(engine.get_job(build_job).await.unwrap().status(), JobStatus::Succeeded);\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","integration_test_helpers.rs"],"content":"//! Integration test helpers shared across all integration tests\n\nuse gitforce_common::{JobId, PipelineId, PipelineRunId, RepoId, RunnerId, UserId};\nuse gitforce_db::models::{Pipeline, PipelineRun, Repository, Runner, RunnerType, User};\nuse gitforce_db::Pool;\nuse gitforce_scheduler::{Scheduler, scheduler_routes, create_state};\nuse gitforce_ci::{PipelineDefinition, PipelineTriggerEvent, TriggerType, JobDefinition, StepDefinition};\nuse std::collections::HashMap;\n\n/// Create a test user\npub fn create_test_user(username: &str) -> User {\n User::new(\n username.to_string(),\n format!(\"{}@example.com\", username),\n \"hash_placeholder\".to_string(),\n )\n}\n\n/// Create a test repository\npub fn create_test_repo(name: &str, owner_id: UserId) -> Repository {\n Repository::new(\n name.to_string(),\n owner_id,\n format!(\"/git/repos/{}\", name),\n )\n}\n\n/// Create a test pipeline\npub fn create_test_pipeline(repo_id: RepoId) -> Pipeline {\n Pipeline {\n id: PipelineId::new(),\n repo_id,\n name: \"Test Pipeline\".to_string(),\n trigger_type: \"push\".to_string(),\n config: serde_json::json!({}),\n created_at: chrono::Utc::now(),\n }\n}\n\n/// Create a test pipeline run\npub fn create_test_pipeline_run(pipeline_id: PipelineId, repo_id: RepoId) -> PipelineRun {\n PipelineRun::new(\n pipeline_id,\n repo_id,\n \"test_user\".to_string(),\n \"abc123def456\".to_string(),\n )\n}\n\n/// Create a test runner\npub fn create_test_runner(name: &str, runner_type: RunnerType) -> Runner {\n Runner::new(name.to_string(), runner_type, 2)\n}\n\n/// Create a simple pipeline definition for testing\npub fn create_simple_pipeline_definition() -> PipelineDefinition {\n PipelineDefinition {\n name: \"test-pipeline\".to_string(),\n version: \"1.0\".to_string(),\n trigger_on: vec![TriggerType::Push],\n environment: HashMap::new(),\n jobs: vec![\n JobDefinition {\n name: \"build\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"build\".to_string(),\n run: \"cargo build\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n JobDefinition {\n name: \"test\".to_string(),\n image: \"rust:latest\".to_string(),\n needs: vec![\"build\".to_string()],\n env: HashMap::new(),\n steps: vec![StepDefinition {\n name: \"test\".to_string(),\n run: \"cargo test\".to_string(),\n env: None,\n working_directory: None,\n condition: None,\n }],\n timeout: None,\n retry: None,\n },\n ],\n }\n}\n\n/// Create a scheduler with test state\npub fn create_test_scheduler() -> Scheduler {\n Scheduler::new()\n}\n\n/// Create scheduler routes for testing\npub fn create_scheduler_test_app() -> axum::Router {\n let scheduler = Scheduler::new();\n let state = create_state(scheduler);\n scheduler_routes(state)\n}\n\n/// Set up a test database pool\npub async fn create_test_db_pool() -> Pool {\n let pool = Pool::memory().await.expect(\"failed to create memory pool\");\n pool.migrate().await.expect(\"failed to run migrations\");\n pool\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","lib.rs"],"content":"//! GitForge Integration Tests\n//!\n//! This crate contains integration tests for GitForge components.\n\npub mod integration_test_helpers;\npub mod scheduler_integration_tests;\npub mod database_integration_tests;\npub mod runner_integration_tests;\npub mod ci_integration_tests;\npub mod e2e_pipeline_tests;\npub mod api_route_tests;","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","runner_integration_tests.rs"],"content":"//! Integration tests for Runner Agent\n//!\n//! These tests verify the runner agent's HTTP communication with scheduler\n//! and job execution functionality.\n\nuse gitforce_runner::{RunnerAgent, RunnerConfig};\nuse gitforce_runner::executor::{ExecutableJob, JobStep, JobResult, JobExecutor};\nuse gitforce_common::JobId;\nuse std::collections::HashMap;\n\n/// Test runner configuration\n#[test]\nfn test_runner_config_default() {\n let config = RunnerConfig::default();\n assert_eq!(config.name, \"runner\");\n assert_eq!(config.runner_type, \"docker\");\n assert_eq!(config.capacity, 2);\n assert_eq!(config.heartbeat_interval_secs, 30);\n assert_eq!(config.fetch_interval_secs, 5);\n assert_eq!(config.scheduler_url, \"http://localhost:8081\");\n}\n\n/// Test runner configuration custom values\n#[test]\nfn test_runner_config_custom() {\n let config = RunnerConfig {\n scheduler_url: \"http://custom:9090\".to_string(),\n name: \"custom-runner\".to_string(),\n runner_type: \"firecracker\".to_string(),\n capacity: 8,\n heartbeat_interval_secs: 60,\n fetch_interval_secs: 10,\n };\n assert_eq!(config.name, \"custom-runner\");\n assert_eq!(config.runner_type, \"firecracker\");\n assert_eq!(config.capacity, 8);\n assert_eq!(config.heartbeat_interval_secs, 60);\n assert_eq!(config.fetch_interval_secs, 10);\n}\n\n/// Test executable job creation\n#[test]\nfn test_executable_job_creation() {\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string());\n assert_eq!(job.image, \"rust:latest\");\n assert_eq!(job.timeout_secs, 3600);\n assert!(job.steps.is_empty());\n}\n\n/// Test executable job with builder pattern\n#[test]\nfn test_executable_job_builder_pattern() {\n let steps = vec![\n JobStep::new(\"build\", \"cargo build\"),\n JobStep::new(\"test\", \"cargo test\"),\n ];\n let mut env = HashMap::new();\n env.insert(\"RUST_BACKTRACE\".to_string(), \"1\".to_string());\n\n let job = ExecutableJob::new(JobId::new(), \"rust:latest\".to_string())\n .with_steps(steps.clone())\n .with_env(env.clone())\n .with_timeout(7200);\n\n assert_eq!(job.steps.len(), 2);\n assert_eq!(job.env.get(\"RUST_BACKTRACE\"), Some(&\"1\".to_string()));\n assert_eq!(job.timeout_secs, 7200);\n}\n\n/// Test job step creation\n#[test]\nfn test_job_step_creation() {\n let step = JobStep::new(\"build\", \"cargo build --release\");\n assert_eq!(step.name, \"build\");\n assert_eq!(step.run, \"cargo build --release\");\n assert!(step.env.is_none());\n assert!(step.working_directory.is_none());\n}\n\n/// Test job step with environment\n#[test]\nfn test_job_step_with_env() {\n let mut env = HashMap::new();\n env.insert(\"CI\".to_string(), \"true\".to_string());\n env.insert(\"NODE_ENV\".to_string(), \"test\".to_string());\n\n let step = JobStep {\n name: \"test\".to_string(),\n run: \"npm test\".to_string(),\n env: Some(env),\n working_directory: Some(\"/app\".to_string()),\n };\n\n assert_eq!(step.name, \"test\");\n assert_eq!(step.run, \"npm test\");\n assert!(step.env.is_some());\n assert_eq!(step.env.as_ref().unwrap().get(\"CI\"), Some(&\"true\".to_string()));\n assert_eq!(step.working_directory, Some(\"/app\".to_string()));\n}\n\n/// Test job result structure\n#[test]\nfn test_job_result_success() {\n let result = JobResult {\n job_id: JobId::new(),\n success: true,\n exit_code: 0,\n step_results: vec![],\n error: None,\n };\n assert!(result.success);\n assert_eq!(result.exit_code, 0);\n assert!(result.error.is_none());\n}\n\n/// Test job result failure\n#[test]\nfn test_job_result_failure() {\n let result = JobResult {\n job_id: JobId::new(),\n success: false,\n exit_code: 1,\n step_results: vec![],\n error: Some(\"build failed\".to_string()),\n };\n assert!(!result.success);\n assert_eq!(result.exit_code, 1);\n assert_eq!(result.error, Some(\"build failed\".to_string()));\n}\n\n/// Test job executor creation (requires Docker, may fail)\n#[tokio::test]\nasync fn test_job_executor_creation() {\n let executor = JobExecutor::new().await;\n // This may fail if Docker is not available\n // In that case we skip - this is expected in CI without Docker\n if executor.is_ok() {\n assert_eq!(executor.unwrap().active_count().await, 0);\n }\n}\n\n/// Test multiple job steps\n#[test]\nfn test_multiple_job_steps() {\n let steps = vec![\n JobStep::new(\"checkout\", \"git clone https://github.com/example/repo.git\"),\n JobStep::new(\"setup\", \"npm install\"),\n JobStep::new(\"build\", \"npm run build\"),\n JobStep::new(\"test\", \"npm test\"),\n JobStep::new(\"deploy\", \"npm run deploy\"),\n ];\n\n let job = ExecutableJob::new(JobId::new(), \"node:18\".to_string())\n .with_steps(steps);\n\n assert_eq!(job.steps.len(), 5);\n assert_eq!(job.steps[0].name, \"checkout\");\n assert_eq!(job.steps[4].name, \"deploy\");\n}","traces":[],"covered":0,"coverable":0},{"path":["/","nas","Temp","repos","GitForge","tests","integration","src","scheduler_integration_tests.rs"],"content":"//! Integration tests for the Scheduler\n//!\n//! These tests verify the scheduler's internal logic.\n//! Note: HTTP route tests are limited due to issues with state passing in the router setup.\n\nuse gitforce_scheduler::{Scheduler, Priority};\nuse gitforce_common::{JobId, PipelineRunId, RepoId, RunnerId};\nuse gitforce_db::models::Runner;\nuse crate::integration_test_helpers::*;\n\n/// Test scheduler job enqueuing\n#[tokio::test]\nasync fn test_scheduler_enqueue() {\n let scheduler = Scheduler::new();\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n}\n\n/// Test scheduler job enqueuing with priority\n#[tokio::test]\nasync fn test_scheduler_enqueue_with_priority() {\n let scheduler = Scheduler::new();\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue_with_priority(job_id, run_id, repo_id, Priority::High).await;\n assert_eq!(scheduler.queue_len().await, 1);\n}\n\n/// Test scheduler job cancellation\n#[tokio::test]\nasync fn test_scheduler_cancel() {\n let scheduler = Scheduler::new();\n\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n assert_eq!(scheduler.queue_len().await, 1);\n\n scheduler.cancel(job_id).await;\n assert_eq!(scheduler.queue_len().await, 0);\n}\n\n/// Test scheduler runner registration\n#[tokio::test]\nasync fn test_scheduler_register_runner() {\n let scheduler = Scheduler::new();\n\n let runner = Runner::new(\"test-runner\".to_string(), gitforce_db::models::RunnerType::Docker, 2);\n let runner_id = runner.id;\n\n scheduler.register_runner(runner).await;\n\n // Verify runner is registered by enqueuing a job and processing\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n scheduler.process_queue().await;\n\n let assigned = scheduler.is_assigned(job_id).await;\n assert_eq!(assigned, Some(runner_id));\n}\n\n/// Test scheduler heartbeat\n#[tokio::test]\nasync fn test_scheduler_heartbeat() {\n let scheduler = Scheduler::new();\n\n let runner = Runner::new(\"test-runner\".to_string(), gitforce_db::models::RunnerType::Docker, 2);\n let runner_id = runner.id;\n\n scheduler.register_runner(runner).await;\n scheduler.heartbeat(runner_id).await;\n // No panic means success\n}\n\n/// Test scheduler runner offline\n#[tokio::test]\nasync fn test_scheduler_runner_offline() {\n let scheduler = Scheduler::new();\n\n let runner = Runner::new(\"test-runner\".to_string(), gitforce_db::models::RunnerType::Docker, 2);\n let runner_id = runner.id;\n\n scheduler.register_runner(runner).await;\n scheduler.runner_offline(runner_id).await;\n\n // Verify runner is offline by enqueuing a job and processing\n let job_id = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id, run_id, repo_id).await;\n scheduler.process_queue().await;\n\n // Job should not be assigned since runner is offline\n let assigned = scheduler.is_assigned(job_id).await;\n assert!(assigned.is_none());\n}\n\n/// Test scheduler cancel nonexistent job\n#[tokio::test]\nasync fn test_scheduler_cancel_nonexistent() {\n let scheduler = Scheduler::new();\n let job_id = JobId::new();\n\n // Cancel should not panic\n scheduler.cancel(job_id).await;\n assert_eq!(scheduler.queue_len().await, 0);\n}\n\n/// Test scheduler multiple runners\n#[tokio::test]\nasync fn test_scheduler_multiple_runners() {\n let scheduler = Scheduler::new();\n\n let runner1 = Runner::new(\"runner-1\".to_string(), gitforce_db::models::RunnerType::Docker, 4);\n let runner2 = Runner::new(\"runner-2\".to_string(), gitforce_db::models::RunnerType::Firecracker, 2);\n\n scheduler.register_runner(runner1.clone()).await;\n scheduler.register_runner(runner2.clone()).await;\n\n // Enqueue jobs\n let job_id1 = JobId::new();\n let job_id2 = JobId::new();\n let run_id = PipelineRunId::new();\n let repo_id = RepoId::new();\n\n scheduler.enqueue(job_id1, run_id, repo_id).await;\n scheduler.enqueue(job_id2, run_id, repo_id).await;\n\n assert_eq!(scheduler.queue_len().await, 2);\n}","traces":[],"covered":0,"coverable":0}],"coverage":82.95510606808091,"covered":3363,"coverable":4054} \ No newline at end of file diff --git a/template-parts/scaffolding/api-service/README.md b/template-parts/scaffolding/api-service/README.md index 652fd616..aec1e8f5 100644 --- a/template-parts/scaffolding/api-service/README.md +++ b/template-parts/scaffolding/api-service/README.md @@ -52,7 +52,7 @@ make docker-build | `APP_PORT` | `8080` | Port to listen on | | `APP_ENV` | `development` | `development` or `production` | | `APP_LOG_LEVEL` | `info` | Log level (debug, info, warn, error) | -| `APP_DB_URL` | `postgres://postgres:password@localhost:5432/apidb?sslmode=disable` | Database connection URL | +| `APP_DB_URL` | `postgres://postgres:${POSTGRES_PASSWORD}@localhost:5432/apidb?sslmode=disable` | Database connection URL | ## API Endpoints @@ -114,7 +114,7 @@ host: "0.0.0.0" port: 8080 env: "development" log_level: "info" -db_url: "postgres://postgres:password@localhost:5432/apidb?sslmode=disable" +db_url: "postgres://postgres:${POSTGRES_PASSWORD}@localhost:5432/apidb?sslmode=disable" ``` ## Docker @@ -167,4 +167,4 @@ api-service/ ## License -MIT \ No newline at end of file +MIT diff --git a/template-parts/scaffolding/api-service/docker-compose.yml b/template-parts/scaffolding/api-service/docker-compose.yml index 9da6f45b..04462143 100644 --- a/template-parts/scaffolding/api-service/docker-compose.yml +++ b/template-parts/scaffolding/api-service/docker-compose.yml @@ -11,7 +11,7 @@ services: APP_PORT: "8080" APP_ENV: "${APP_ENV:-development}" APP_LOG_LEVEL: "${APP_LOG_LEVEL:-info}" - APP_DB_URL: "postgres://postgres:password@postgres:5432/apidb?sslmode=disable" + APP_DB_URL: "postgres://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/apidb?sslmode=disable" depends_on: postgres: condition: service_healthy @@ -32,7 +32,7 @@ services: container_name: api-postgres environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" POSTGRES_DB: apidb ports: - "5432:5432" @@ -69,4 +69,4 @@ volumes: networks: api-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/template-parts/scaffolding/api-service/src/config/config.go b/template-parts/scaffolding/api-service/src/config/config.go index fb061b1f..5de829f1 100644 --- a/template-parts/scaffolding/api-service/src/config/config.go +++ b/template-parts/scaffolding/api-service/src/config/config.go @@ -21,7 +21,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("port", 8080) v.SetDefault("env", "development") v.SetDefault("log_level", "info") - v.SetDefault("db_url", "postgres://postgres:password@localhost:5432/apidb?sslmode=disable") + v.SetDefault("db_url", "postgres://postgres:${POSTGRES_PASSWORD}@localhost:5432/apidb?sslmode=disable") } // Load reads configuration from file and environment variables. diff --git a/template-parts/scaffolding/worker-service/docker-compose.yml b/template-parts/scaffolding/worker-service/docker-compose.yml index 95342bd6..5c24125e 100644 --- a/template-parts/scaffolding/worker-service/docker-compose.yml +++ b/template-parts/scaffolding/worker-service/docker-compose.yml @@ -7,7 +7,7 @@ services: environment: POSTGRES_DB: workerdb POSTGRES_USER: worker - POSTGRES_PASSWORD: workerpassword + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -52,7 +52,7 @@ services: condition: service_healthy environment: AMQP_URL: "amqp://guest:guest@rabbitmq:5672/" - DATABASE_DSN: "postgres://worker:workerpassword@postgres:5432/workerdb?sslmode=disable" + DATABASE_DSN: "postgres://worker:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/workerdb?sslmode=disable" WORKER_COUNT: "4" LOG_LEVEL: "info" SHUTDOWN_TIMEOUT: "30" diff --git a/tests/integration/src/api_route_tests.rs b/tests/integration/src/api_route_tests.rs index f1f98958..f5b86029 100644 --- a/tests/integration/src/api_route_tests.rs +++ b/tests/integration/src/api_route_tests.rs @@ -110,7 +110,7 @@ async fn test_api_auth_invalid_token() { assert!(result.is_err()); // Malformed JWT - let result = auth.validate_token("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature"); + let result = auth.validate_token("not-a-jwt"); assert!(result.is_err()); } @@ -236,4 +236,4 @@ fn test_runner_id_conversion() { let runner_id = RunnerId::from(uuid); let back = uuid::Uuid::from(runner_id); assert_eq!(uuid, back); -} \ No newline at end of file +}