oxide-sloc 1.6.18

Source line analysis tool — CLI, web UI, HTML/PDF reports, test metrics, git hotspots & code ownership, and CI/CD integration
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>

//! Shared plumbing for the Atlassian family of integrations (Confluence, Jira,
//! Bitbucket). All three products share the same deployment-tier split
//! (Cloud vs. Server/Data Center), the same Basic-or-Bearer auth model, and the
//! same need to render an SLOC summary into a product-native body. Factoring the
//! logic here keeps the individual `send`/`jira`/`bitbucket` handlers in
//! `main.rs` from re-deriving it.
//!
//! The primary target for the on-premises deployments this tool serves is
//! Server/Data Center (built-in REST, no marketplace app). Cloud rendering is
//! retained as an auto-detected fallback so a Cloud base URL still works.

use anyhow::Result;

use sloc_core::AnalysisRun;

/// Atlassian deployment tier, inferred from the base URL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AtlassianTier {
    /// Atlassian Cloud (`*.atlassian.net`) — REST v2/v3, ADF comment bodies.
    Cloud,
    /// Server / Data Center (self-hosted) — REST v1/v2, wiki-markup bodies.
    ServerDc,
}

/// Detect the deployment tier from a base URL. Atlassian Cloud tenants always
/// live under `*.atlassian.net`; anything else is treated as Server/Data Center.
pub fn detect_tier(base_url: &str) -> AtlassianTier {
    if base_url.to_lowercase().contains(".atlassian.net") {
        AtlassianTier::Cloud
    } else {
        AtlassianTier::ServerDc
    }
}

/// Build an `Authorization` header value for an Atlassian REST call.
///
/// A non-empty username selects HTTP Basic (`user:token` base64-encoded) — the
/// shape Cloud uses for email + API token and Server uses for username +
/// password. An absent/empty username selects `Bearer <token>` — the shape used
/// by personal access tokens (Server/DC) and scoped access tokens (Cloud).
pub fn atlassian_auth(username: Option<&str>, token: &str) -> String {
    use base64::Engine as _;
    match username {
        Some(u) if !u.is_empty() => {
            let enc = base64::engine::general_purpose::STANDARD.encode(format!("{u}:{token}"));
            format!("Basic {enc}")
        }
        _ => format!("Bearer {token}"),
    }
}

/// The canonical, ordered list of headline metrics rendered into every Atlassian
/// body (Jira comment, Bitbucket Code Insights, etc.). Built once from the run's
/// summary totals and git metadata so the wiki and ADF renderers stay in sync.
pub fn summary_facts(run: &AnalysisRun) -> Vec<(String, String)> {
    let t = &run.summary_totals;
    let mut facts = vec![
        (
            "Files analyzed".to_string(),
            crate::fmt_thousands(t.files_analyzed),
        ),
        ("Code lines".to_string(), crate::fmt_thousands(t.code_lines)),
        (
            "Comment lines".to_string(),
            crate::fmt_thousands(t.comment_lines),
        ),
        (
            "Blank lines".to_string(),
            crate::fmt_thousands(t.blank_lines),
        ),
        (
            "Languages".to_string(),
            run.totals_by_language.len().to_string(),
        ),
    ];
    if let Some(branch) = run.git_branch.as_deref() {
        facts.push(("Branch".to_string(), branch.to_string()));
    }
    if let Some(commit) = run.git_commit_short.as_deref() {
        facts.push(("Commit".to_string(), commit.to_string()));
    }
    facts
}

/// Render the SLOC summary as Jira/Confluence Server/DC wiki markup — the body
/// format the v2 comment REST endpoint expects.
pub fn render_wiki_summary(run: &AnalysisRun, report_url: Option<&str>) -> String {
    use std::fmt::Write as _;
    let mut out = String::new();
    out.push_str("h2. SLOC Report\n\n");
    out.push_str("||Metric||Value||\n");
    for (k, v) in summary_facts(run) {
        writeln!(out, "|{k}|{v}|").expect("write to String is infallible");
    }
    if let Some(url) = report_url {
        writeln!(out, "\n[View full report|{url}]").expect("write to String is infallible");
    }
    out.push_str("\n_Generated by oxide-sloc._\n");
    out
}

/// Render the SLOC summary as an Atlassian Document Format (ADF) document — the
/// body format the Jira Cloud v3 comment REST endpoint expects. Kept as a
/// fallback for Cloud base URLs; Server/DC uses [`render_wiki_summary`] instead.
pub fn render_adf_summary(run: &AnalysisRun, report_url: Option<&str>) -> serde_json::Value {
    let mut content = vec![serde_json::json!({
        "type": "heading",
        "attrs": { "level": 2 },
        "content": [ { "type": "text", "text": "SLOC Report" } ]
    })];

    let items: Vec<serde_json::Value> = summary_facts(run)
        .into_iter()
        .map(|(k, v)| {
            serde_json::json!({
                "type": "listItem",
                "content": [ {
                    "type": "paragraph",
                    "content": [ { "type": "text", "text": format!("{k}: {v}") } ]
                } ]
            })
        })
        .collect();
    content.push(serde_json::json!({ "type": "bulletList", "content": items }));

    if let Some(url) = report_url {
        content.push(serde_json::json!({
            "type": "paragraph",
            "content": [ {
                "type": "text",
                "text": "View full report",
                "marks": [ { "type": "link", "attrs": { "href": url } } ]
            } ]
        }));
    }

    serde_json::json!({ "version": 1, "type": "doc", "content": content })
}

/// One-line plain-text summary used for remote-link summaries and custom-field
/// values (surfaces that take a scalar, not a rendered body).
pub fn summary_oneline(run: &AnalysisRun) -> String {
    let t = &run.summary_totals;
    format!(
        "{} files, {} code, {} comment, {} blank across {} languages",
        crate::fmt_thousands(t.files_analyzed),
        crate::fmt_thousands(t.code_lines),
        crate::fmt_thousands(t.comment_lines),
        crate::fmt_thousands(t.blank_lines),
        run.totals_by_language.len(),
    )
}

/// Guard every outbound Atlassian POST through the same SSRF checks the webhook
/// and PR-comment paths use. On-premises Server/DC hosts on RFC-1918 ranges
/// require the explicit `allow_private_net` opt-in — intentionally stricter than
/// the shell `notify-*.sh` scripts, which do no filtering.
pub fn atlassian_ssrf_check(url: &str, allow_private_net: bool) -> Result<()> {
    crate::validate_webhook_url(url, allow_private_net)
}

#[cfg(test)]
mod tests {
    use super::*;
    use sloc_core::AnalysisRun;

    fn sample_run() -> AnalysisRun {
        let json = serde_json::json!({
            "tool": {
                "name": "oxide-sloc",
                "version": "0.0.0",
                "run_id": "test",
                "timestamp_utc": "2026-01-01T00:00:00Z"
            },
            "environment": {
                "operating_system": "test-os",
                "architecture": "x86_64",
                "runtime_mode": "cli",
                "initiator_username": "tester",
                "initiator_hostname": "host"
            },
            "effective_configuration": {},
            "input_roots": ["."],
            "summary_totals": {
                "files_considered": 12,
                "files_analyzed": 12,
                "files_skipped": 0,
                "total_physical_lines": 4368,
                "code_lines": 3456,
                "comment_lines": 789,
                "blank_lines": 123,
                "mixed_lines_separate": 0
            },
            "totals_by_language": [
                { "language": "rust", "files": 10, "total_physical_lines": 4000,
                  "code_lines": 3400, "comment_lines": 700, "blank_lines": 100,
                  "mixed_lines_separate": 0 },
                { "language": "shell", "files": 2, "total_physical_lines": 400,
                  "code_lines": 56, "comment_lines": 89, "blank_lines": 23,
                  "mixed_lines_separate": 0 }
            ],
            "per_file_records": [],
            "skipped_file_records": [],
            "warnings": [],
            "git_branch": "main",
            "git_commit_short": "abc1234"
        });
        serde_json::from_value(json).expect("sample AnalysisRun deserializes")
    }

    #[test]
    fn detect_tier_cloud_vs_server() {
        assert_eq!(
            detect_tier("https://myco.atlassian.net"),
            AtlassianTier::Cloud
        );
        assert_eq!(
            detect_tier("https://MYCO.ATLASSIAN.NET/wiki"),
            AtlassianTier::Cloud
        );
        assert_eq!(
            detect_tier("https://jira.corp.com/"),
            AtlassianTier::ServerDc
        );
        assert_eq!(
            detect_tier("https://confluence.internal:8090"),
            AtlassianTier::ServerDc
        );
    }

    #[test]
    fn auth_basic_with_username_bearer_without() {
        use base64::Engine as _;
        let basic = atlassian_auth(Some("alice@corp.com"), "tok123");
        let expected = base64::engine::general_purpose::STANDARD.encode("alice@corp.com:tok123");
        assert_eq!(basic, format!("Basic {expected}"));

        assert_eq!(atlassian_auth(None, "pat-xyz"), "Bearer pat-xyz");
        assert_eq!(atlassian_auth(Some(""), "pat-xyz"), "Bearer pat-xyz");
    }

    #[test]
    fn wiki_summary_has_header_and_counts() {
        let run = sample_run();
        let wiki = render_wiki_summary(&run, Some("https://reports.corp/r/1.html"));
        assert!(wiki.contains("h2. SLOC Report"));
        assert!(wiki.contains("||Metric||Value||"));
        assert!(wiki.contains("|Code lines|3,456|"));
        assert!(wiki.contains("|Languages|2|"));
        assert!(wiki.contains("[View full report|https://reports.corp/r/1.html]"));
    }

    #[test]
    fn adf_summary_is_valid_doc() {
        let run = sample_run();
        let adf = render_adf_summary(&run, None);
        assert_eq!(adf["version"], 1);
        assert_eq!(adf["type"], "doc");
        // heading + bulletList (no link paragraph when report_url is None)
        assert_eq!(adf["content"].as_array().map(Vec::len), Some(2));

        let with_link = render_adf_summary(&run, Some("https://reports.corp/r/1.html"));
        assert_eq!(with_link["content"].as_array().map(Vec::len), Some(3));
    }

    #[test]
    fn ssrf_guard_blocks_metadata_allows_optin() {
        assert!(atlassian_ssrf_check("http://169.254.169.254/latest/meta-data", false).is_err());
        // On-prem RFC-1918 host requires the explicit opt-in.
        assert!(
            atlassian_ssrf_check("http://10.0.0.5/rest/api/2/issue/X-1/comment", false).is_err()
        );
        assert!(atlassian_ssrf_check("http://10.0.0.5/rest/api/2/issue/X-1/comment", true).is_ok());
        assert!(
            atlassian_ssrf_check("https://jira.corp.com/rest/api/2/issue/X-1/comment", false)
                .is_ok()
        );
    }
}