acorn-lib 0.1.70

ACORN library
Documentation
use super::*;
use crate::analyzer::discovery::ArtifactCandidate;
use crate::io::api::citeas;
use crate::io::api::gitlab::intake::{analyze_work_item, verified_command_author, Authorization};
use crate::io::api::gitlab::{webhook, HookActor, Note, WorkItem, WorkItemUser};
use crate::prelude::Mutex;
use crate::schema::pid::Identifier;
use crate::schema::standard::cff::Cff;
use crate::test::server::TestServer;
use crate::util::constants::app::WORK_ITEM_REPORT_MARKER;
use alloc::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};

#[derive(Clone, Default)]
struct TestState {
    written: Arc<Mutex<Vec<serde_json::Value>>>,
    repository_url: Arc<Mutex<String>>,
}

fn note_json(identifier: u64, body: &str, author_id: u64) -> serde_json::Value {
    serde_json::json!({
        "id": identifier,
        "body": body,
        "author": {"id": author_id, "username": format!("user-{author_id}"), "bot": author_id == 99},
        "system": false,
        "confidential": false,
        "internal": false
    })
}

fn written_note(body: &serde_json::Value) -> serde_json::Value {
    serde_json::json!({
        "id": 80,
        "body": body.get("body").and_then(serde_json::Value::as_str).unwrap_or_default(),
        "author": {"id": 99, "username": "acorn-bot", "bot": true},
        "system": false,
        "confidential": false,
        "internal": false
    })
}
#[test]
fn content_excludes_commands_and_non_human_or_later_notes() {
    let item = WorkItem {
        identifier: 10,
        iid: 7,
        project_id: 30,
        title: "Result".to_string(),
        description: "doi:10.1234/example".to_string(),
        author: WorkItemUser::new(1, "user-1", false),
        issue_type: "issue".to_string(),
        confidential: false,
    };
    let note = |identifier, body: &str, author, system| Note::WorkItem {
        identifier,
        body: body.to_string(),
        author,
        system,
        confidential: false,
        internal: false,
    };
    let content = item.content(
        &[
            note(2, "Human context", WorkItemUser::new(2, "user-2", false), false),
            note(3, "/acorn check", WorkItemUser::new(2, "user-2", false), false),
            note(4, "Bot context", WorkItemUser::new(9, "user-9", true), false),
            note(5, "System context", WorkItemUser::new(2, "user-2", false), true),
            note(11, "Later context", WorkItemUser::new(2, "user-2", false), false),
        ],
        10,
        9,
    );
    assert!(content.contains("Human context"));
    assert!(!content.contains("/acorn check"));
    assert!(!content.contains("Bot context"));
    assert!(!content.contains("System context"));
    assert!(!content.contains("Later context"));
}
#[test]
fn embedded_cff_is_complete_and_identifier_only_candidate_is_excluded() {
    let content = "```cff\ncff-version: 1.2.0\ntitle: Existing result\nmessage: Cite this\nauthors:\n  - family-names: Example\n    given-names: Alice\ndoi: 10.1234/example\n```";
    let candidate = ArtifactCandidate::from(Cff::embedded(content).into_iter().next().unwrap());
    assert!(candidate.classify(None).cff.is_some());
    let identifier = Identifier::new("https://example.org/artifact").normalize().unwrap();
    let excluded = ArtifactCandidate {
        identifiers: vec![identifier],
        ..ArtifactCandidate::default()
    }
    .classify(Some("metadata unavailable".to_string()));
    assert_eq!(excluded.missing, ["Title", "Authors"]);
    assert!(excluded.cff.is_none());
}
#[test]
fn citeas_metadata_completes_a_doi_candidate() {
    let identifier = Identifier::new("doi:10.1234/example").normalize().unwrap();
    let candidate = ArtifactCandidate {
        identifiers: vec![identifier],
        ..ArtifactCandidate::default()
    };
    let citations = citeas::Citations {
        metadata: citeas::Metadata {
            title: "Enriched result".to_string(),
            author: vec![citeas::Author {
                given: "Alice".to_string(),
                family: "Example".to_string(),
            }],
            url: "https://doi.org/10.1234/example".to_string(),
            ..citeas::Metadata::default()
        },
        ..citeas::Citations::default()
    };
    let enriched = candidate.apply_citeas(citations);
    assert_eq!(enriched.title.as_deref(), Some("Enriched result"));
    assert_eq!(enriched.authors, ["Alice Example"]);
    assert!(enriched.classify(None).cff.is_some());
}
#[test]
fn command_matching_requires_an_exact_trimmed_line() {
    assert!(webhook::check_requested(" /acorn check \n"));
    assert!(!webhook::check_requested("Please /acorn check"));
    assert!(!webhook::check_requested("/acorn check now"));
    let command = Note::WorkItem {
        identifier: 10,
        body: "/acorn check".to_string(),
        author: WorkItemUser::new(1, "user-1", false),
        system: false,
        confidential: false,
        internal: false,
    };
    assert_eq!(
        verified_command_author(core::slice::from_ref(&command), 10, &HookActor::new(30, 1, "user-1", false)),
        Some(1)
    );
    assert_eq!(verified_command_author(&[command], 10, &HookActor::new(30, 2, "user-2", false)), None);
}
#[tokio::test]
async fn creator_issue_intake_reports_multiple_incomplete_artifacts() {
    let state = TestState::default();
    let router = Router::new()
        .route("/api/v4/user", get(|| async { Json(serde_json::json!({"id": 99})) }))
        .route(
            "/api/v4/projects/30/issues/7",
            get(|| async {
                Json(serde_json::json!({
                    "id": 70,
                    "iid": 7,
                    "project_id": 30,
                    "title": "Research outputs",
                    "description": "https://example.org/first https://example.org/second",
                    "author": {"id": 1, "username": "scientist"},
                    "issue_type": "issue"
                }))
            }),
        )
        .route(
            "/api/v4/projects/30/issues/7/notes",
            get(|| async { Json(serde_json::json!([note_json(2, "Human context", 3), note_json(10, "/acorn check", 1)])) }).post(
                |State(state): State<TestState>, Json(body): Json<serde_json::Value>| async move {
                    state.written.lock().unwrap().push(body.clone());
                    Json(written_note(&body))
                },
            ),
        )
        .with_state(state.clone());
    let server = TestServer::start(router).await.unwrap();
    let options = Options::with_token("outbound")
        .domain(&server.base_url)
        .identifier("30")
        .internal_identifier("7")
        .build();
    let report = analyze_work_item(&options, &HookActor::new(30, 1, "user-1", false), 10).await.unwrap();
    assert_eq!(report.authorization, Authorization::Creator);
    assert_eq!(report.candidates.len(), 2);
    assert!(report.candidates.iter().all(|candidate| candidate.cff.is_none()));
    assert_eq!(state.written.lock().unwrap().len(), 1);
    server.stop().await.unwrap();
}
#[tokio::test]
async fn existing_gitlab_repository_cff_completes_candidate() {
    let state = TestState::default();
    let cff = "cff-version: 1.2.0\ntitle: Repository result\nmessage: Cite this\nauthors:\n  - family-names: Example\n    given-names: Alice\n";
    let encoded_cff = data_encoding::BASE64.encode(cff.as_bytes());
    let router = Router::new()
        .route("/api/v4/user", get(|| async { Json(serde_json::json!({"id": 99})) }))
        .route(
            "/api/v4/projects/30/issues/7",
            get(|State(state): State<TestState>| async move {
                let repository_url = state.repository_url.lock().unwrap().clone();
                Json(serde_json::json!({
                    "id": 70,
                    "iid": 7,
                    "project_id": 30,
                    "title": "Repository output",
                    "description": repository_url,
                    "author": {"id": 1, "username": "scientist"},
                    "issue_type": "issue"
                }))
            }),
        )
        .route(
            "/api/v4/projects/group%2Fproject/repository/files/CITATION.cff",
            get(move || {
                let encoded_cff = encoded_cff.clone();
                async move {
                    Json(serde_json::json!({
                        "file_path": "CITATION.cff",
                        "size": cff.len(),
                        "encoding": "base64",
                        "content": encoded_cff
                    }))
                }
            }),
        )
        .route(
            "/api/v4/projects/30/issues/7/notes",
            get(|| async { Json(serde_json::json!([note_json(10, "/acorn check", 1)])) }).post(
                |State(state): State<TestState>, Json(body): Json<serde_json::Value>| async move {
                    state.written.lock().unwrap().push(body.clone());
                    Json(written_note(&body))
                },
            ),
        )
        .with_state(state.clone());
    let server = TestServer::start(router).await.unwrap();
    *state.repository_url.lock().unwrap() = format!("{}/group/project", server.base_url);
    let options = Options::with_token("outbound")
        .domain(&server.base_url)
        .identifier("30")
        .internal_identifier("7")
        .build();
    let report = analyze_work_item(&options, &HookActor::new(30, 1, "user-1", false), 10).await.unwrap();
    assert_eq!(report.candidates.len(), 1);
    let candidate = report.candidates.first().unwrap();
    assert_eq!(candidate.artifact.title.as_deref(), Some("Repository result"));
    assert!(candidate.cff.is_some());
    server.stop().await.unwrap();
}
#[tokio::test]
async fn maintainer_task_intake_updates_existing_report_with_embedded_cff() {
    let state = TestState::default();
    let cff = "```cff\ncff-version: 1.2.0\ntitle: Existing result\nmessage: Cite this\nauthors:\n  - family-names: Example\n    given-names: Alice\ndoi: 10.1234/example\n```";
    let notes = vec![
        note_json(2, cff, 1),
        note_json(10, "/acorn check", 2),
        note_json(80, WORK_ITEM_REPORT_MARKER, 99),
    ];
    let router = Router::new()
        .route("/api/v4/user", get(|| async { Json(serde_json::json!({"id": 99})) }))
        .route(
            "/api/v4/projects/30/issues/7",
            get(|| async {
                Json(serde_json::json!({
                    "id": 70,
                    "iid": 7,
                    "project_id": 30,
                    "title": "Task result",
                    "description": "",
                    "author": {"id": 1, "username": "scientist"},
                    "issue_type": "task"
                }))
            }),
        )
        .route(
            "/api/v4/projects/30/members/all/2",
            get(|| async { Json(serde_json::json!({"id": 2, "access_level": 40})) }),
        )
        .route(
            "/api/v4/projects/30/issues/7/notes",
            get(move || {
                let notes = notes.clone();
                async move { Json(notes) }
            }),
        )
        .route(
            "/api/v4/projects/30/issues/7/notes/80",
            put(|State(state): State<TestState>, Json(body): Json<serde_json::Value>| async move {
                state.written.lock().unwrap().push(body.clone());
                Json(written_note(&body))
            }),
        )
        .with_state(state.clone());
    let server = TestServer::start(router).await.unwrap();
    let options = Options::with_token("outbound")
        .domain(&server.base_url)
        .identifier("30")
        .internal_identifier("7")
        .build();
    let report = analyze_work_item(&options, &HookActor::new(30, 2, "user-2", false), 10).await.unwrap();
    assert_eq!(report.authorization, Authorization::Maintainer);
    assert_eq!(report.candidates.len(), 1);
    assert!(report.candidates.first().unwrap().cff.is_some());
    {
        let written = state.written.lock().unwrap();
        assert_eq!(written.len(), 1);
        assert!(written
            .first()
            .unwrap()
            .get("body")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|body| body.contains("Existing result")));
    }
    server.stop().await.unwrap();
}
#[tokio::test]
async fn reporter_and_membership_lookup_failure_are_denied_safely() {
    for member_response in [
        (StatusCode::OK, serde_json::json!({"id": 2, "access_level": 20})),
        (StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({"message": "lookup failed"})),
    ] {
        let state = TestState::default();
        let router = Router::new()
            .route("/api/v4/user", get(|| async { Json(serde_json::json!({"id": 99})) }))
            .route(
                "/api/v4/projects/30/issues/7",
                get(|| async {
                    Json(serde_json::json!({
                        "id": 70,
                        "iid": 7,
                        "project_id": 30,
                        "title": "Restricted result",
                        "description": "https://example.org/private",
                        "author": {"id": 1, "username": "scientist"},
                        "issue_type": "issue"
                    }))
                }),
            )
            .route(
                "/api/v4/projects/30/members/all/2",
                get(move || {
                    let (status, body) = member_response.clone();
                    async move { (status, Json(body)) }
                }),
            )
            .route(
                "/api/v4/projects/30/issues/7/notes",
                get(|| async { Json(serde_json::json!([note_json(10, "/acorn check", 2)])) }).post(
                    |State(state): State<TestState>, Json(body): Json<serde_json::Value>| async move {
                        state.written.lock().unwrap().push(body.clone());
                        Json(written_note(&body))
                    },
                ),
            )
            .with_state(state.clone());
        let server = TestServer::start(router).await.unwrap();
        let options = Options::with_token("outbound")
            .domain(&server.base_url)
            .identifier("30")
            .internal_identifier("7")
            .build();
        let report = analyze_work_item(&options, &HookActor::new(30, 2, "user-2", false), 10).await.unwrap();
        assert!(matches!(report.authorization, Authorization::Denied { .. }));
        assert!(report.candidates.is_empty());
        assert!(state
            .written
            .lock()
            .unwrap()
            .first()
            .unwrap()
            .get("body")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|body| body.contains("No repository changes were made")));
        server.stop().await.unwrap();
    }
}