acorn-lib 0.3.2

ACORN library
#![allow(clippy::indexing_slicing, clippy::unwrap_used)]
mod robustness;

use super::migrate::Migration;
use super::*;
use crate::test::utils::fixture_path;
use acorn_schema::agent::opencode;
use acorn_schema::research_activity::Sections;
use acorn_schema::standard::cff::IdentifierType;
use acorn_schema::standard::text::Document;
use acorn_schema::standard::zon;
use std::fs::{read, write};
use tempfile::tempdir;

#[test]
fn test_cff_input_output_read_and_write_json() {
    let directory = tempdir().expect("temporary directory should be created");
    let path = directory.path().join("CITATION.json");
    let cff = Cff {
        title: "Roundtrip JSON".to_string(),
        message: "Message".to_string(),
        cff_version: "1.2.0".to_string(),
        ..Cff::default()
    };
    cff.write_json(&path).expect("failed to write CFF JSON");
    let read_back = Cff::read_json(path).expect("failed to read CFF JSON");
    assert_eq!(read_back.title, "Roundtrip JSON");
    assert_eq!(read_back.message, "Message");
}
#[test]
fn test_cff_input_output_read_and_write_yaml() {
    let directory = tempdir().expect("temporary directory should be created");
    let path = directory.path().join("CITATION.yaml");
    let cff = Cff {
        title: "Roundtrip YAML".to_string(),
        message: "Message".to_string(),
        cff_version: "1.2.0".to_string(),
        ..Cff::default()
    };
    cff.write_yaml(&path).expect("failed to write CFF YAML");
    let read_back = Cff::read_yaml(path).expect("failed to read CFF YAML");
    assert_eq!(read_back.title, "Roundtrip YAML");
    assert_eq!(read_back.message, "Message");
}
#[test]
fn test_document_input_output_reads_docx_fixture() {
    let result = Document::read(fixture_path("acorn.docx")).expect("failed to read DOCX file");
    assert!(result.content.contains("ACORN"));
}
#[test]
fn test_document_input_output_rejects_docx_write() {
    let directory = tempdir().expect("temporary directory should be created");
    let source = Document {
        content: "plain text content".to_string(),
    };
    assert!(source.write(directory.path().join("output.docx")).is_err());
}
#[test]
fn test_field_migrations_detect_deprecated_rad_paths() {
    let value = serde_json::json!({
        "meta": {"media": [{"href": "image.png"}, {"url": "video.mp4"}]},
        "contact": {"profile": "https://example.com"},
        "sections": {"outcomes": ["Outcome"]}
    });
    assert_eq!(
        ResearchActivity::field_migrations(&value),
        vec![
            ("meta.media[].href", "meta.media[].contentUrl"),
            ("meta.media[].url", "meta.media[].contentUrl"),
            ("contact.profile", "contact.url"),
            ("sections.outcomes", "sections.impact")
        ]
    );
}
#[test]
fn test_logbook_input_output_round_trips_supported_formats() {
    let directory = tempdir().expect("temporary directory should be created");
    let entry = LogbookEntry::init()
        .identifier("first-entry")
        .timestamp("2026-09-01T12:00:00Z")
        .category("other")
        .state("completed")
        .summary("First entry")
        .build();
    let logbook = Logbook::init().entries(vec![entry.clone()]).build();
    [
        directory.path().join("logbook.json"),
        directory.path().join("logbook.md"),
        directory.path().join("logbook.yaml"),
    ]
    .into_iter()
    .for_each(|path| {
        logbook.write(&path).expect("logbook should write");
        let parsed = Logbook::read(path).expect("logbook should read");
        assert_eq!(parsed.entries[0].identifier, "first-entry");
    });
    let path = directory.path().join("entry.md");
    entry.write(&path).expect("entry should write");
    let parsed = LogbookEntry::read(path).expect("entry should read");
    assert_eq!(parsed.identifier, "first-entry");
}
#[test]
fn test_malformed_jsonc_reports_error() {
    let content = r#"{
        // Valid comment
        "title": 123,
        "sections": "not an object"
    }"#;
    let value = jsonc_parse_value(content).expect("valid JSONC syntax should parse");
    let json = serde_json::to_string(&value).expect("JSONC value should serialize");
    assert!(eserde::json::from_str::<ResearchActivity>(&json).is_err());
}
#[test]
fn test_migrate_allows_strict_read() {
    let activity = ResearchActivity::init().sections(Sections::default()).build();
    let mut value = serde_json::to_value(activity).expect("RAD should serialize");
    value["meta"]["media"] = serde_json::json!([{"caption": "Caption", "href": "image.png"}]);
    let impact = value["sections"].as_object_mut().expect("sections is an object").remove("impact");
    value["sections"]["outcomes"] = impact.expect("serialized sections contain impact");
    let directory = tempdir().expect("temporary directory should be created");
    let path = directory.path().join("legacy.json");
    write(&path, serde_json::to_vec(&value).expect("legacy RAD should serialize")).expect("legacy RAD fixture should be written");
    assert!(ResearchActivity::read(path.clone()).is_ok());
    let why = ResearchActivity::read_strict(path.clone()).expect_err("strict reads should reject deprecated fields");
    assert!(why.to_string().contains("meta.media[].href"));
    assert!(why.to_string().contains("meta.media[].contentUrl"));
    assert!(ResearchActivity::migrate(path.clone()).expect("legacy RAD should migrate"));
    assert!(ResearchActivity::read_strict(path.clone()).is_ok());
    let canonical: serde_json::Value =
        serde_json::from_slice(&read(&path).expect("canonical RAD should be read")).expect("canonical RAD should deserialize as JSON");
    assert_eq!(canonical["meta"]["media"][0]["contentUrl"], "image.png");
    assert!(canonical["meta"]["media"][0].get("href").is_none());
    assert!(canonical["sections"].get("impact").is_some());
    assert!(canonical["sections"].get("outcomes").is_none());
    assert!(!ResearchActivity::migrate(path).expect("canonical RAD should remain unchanged"));
    let rad_with_handle = ResearchActivity::init()
        .meta(ResearchActivityMetadata::init().handle(vec!["hdl:10013/epic.10033".to_string()]).build())
        .build();
    let cff_with_handle: Cff = rad_with_handle.into();
    let identifier = cff_with_handle
        .identifiers
        .and_then(|identifiers| identifiers.into_iter().next())
        .expect("Handle should be exported as a CFF identifier");
    assert_eq!(identifier.kind, IdentifierType::Other);
    assert_eq!(identifier.value, "10013/epic.10033");
    assert_eq!(identifier.description.as_deref(), Some("Handle identifier"));
}
#[test]
fn test_opencode_config_from_jsonc_file() {
    let config = opencode::Config::read(fixture_path("config/opencode.jsonc")).expect("JSONC fixture should load");
    assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
    assert_eq!(
        config.agent.and_then(|agents| agents.get("build").cloned()).and_then(|agent| agent.steps),
        Some(50)
    );
}

#[test]
fn test_opencode_config_from_jsonc_rejects_invalid() {
    assert!(parse_jsonc_cst::<opencode::Config>(r#"{ model: invalid }"#).is_err());
}

#[test]
fn test_opencode_config_from_jsonc_string() {
    let content = r#"{
        // MCP server config
        "mcp": {
            "local-tool": {
                "type": "local",
                "command": ["npx", "my-tool"],
                "enabled": true
            }
        }
    }"#;
    let (config, _) = parse_jsonc_cst::<opencode::Config>(content).expect("JSONC should parse");
    assert!(config.mcp.is_some_and(|mcp| mcp.contains_key("local-tool")));
}
#[test]
fn test_opencode_config_from_jsonc_with_trailing_commas() {
    let content = r#"{
        "model": "openai/gpt-4o",
        "logLevel": "DEBUG",
    }"#;
    let (config, _) = parse_jsonc_cst::<opencode::Config>(content).expect("JSONC should parse");
    assert_eq!(config.model.as_deref(), Some("openai/gpt-4o"));
}
#[test]
fn test_opencode_config_read_json_round_trip() {
    let directory = tempdir().expect("temporary directory should be created");
    let path = directory.path().join("opencode.json");
    let config: opencode::Config =
        serde_json::from_str(r#"{"model":"anthropic/claude-sonnet-4-5","logLevel":"INFO"}"#).expect("OpenCode JSON should parse");
    config.write_json(&path).expect("OpenCode JSON should write");
    let loaded = opencode::Config::read_json(path).expect("OpenCode JSON should read");
    assert_eq!(loaded.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
    assert!(loaded.log_level.is_some());
}
#[test]
fn test_opencode_config_read_jsonc_file() {
    let config = opencode::Config::read(fixture_path("config/opencode.jsonc")).expect("JSONC fixture should load");
    assert_eq!(config.model.as_deref(), Some("anthropic/claude-sonnet-4-5"));
}
#[test]
fn test_opencode_config_write_dispatches_by_extension() {
    let directory = tempdir().expect("temporary directory should be created");
    let json = directory.path().join("opencode.json");
    let jsonc = directory.path().join("opencode.jsonc");
    let config: opencode::Config = serde_json::from_str(r#"{"model":"test"}"#).expect("OpenCode JSON should parse");
    config.write(&json).expect("JSON should write");
    config.write(&jsonc).expect("JSONC should write");
    assert_eq!(opencode::Config::read(json).expect("JSON should read").model.as_deref(), Some("test"));
    assert_eq!(opencode::Config::read(jsonc).expect("JSONC should read").model.as_deref(), Some("test"));
}
#[test]
fn test_read_jsonc_rejects_single_quotes() {
    assert!(jsonc_parse_value(r#"{'title': 'single quotes'}"#).is_err());
}
#[test]
fn test_read_jsonc_rejects_unquoted_keys() {
    assert!(jsonc_parse_value(r#"{ title: 'unquoted' }"#).is_err());
}
#[test]
fn test_read_jsonc_with_comments() {
    let value = jsonc_parse_value(
        r#"{
            // This is a comment
            "title": "JSONC Project",
            "sections": {"mission": "Mission statement"}
        }"#,
    )
    .expect("JSONC comments should parse");
    assert_eq!(value["title"], "JSONC Project");
    assert_eq!(value["sections"]["mission"], "Mission statement");
}
#[test]
fn test_read_jsonc_with_trailing_commas() {
    let value = jsonc_parse_value(
        r#"{
            "title": "Trailing Commas",
            "sections": {"approach": ["Approach",],},
        }"#,
    )
    .expect("JSONC trailing commas should parse");
    assert_eq!(value["title"], "Trailing Commas");
}
#[test]
fn test_research_activity_format() {
    let changed = fixture_path("data/format/changes");
    let pre = ResearchActivity::read(changed.join("index.json")).expect("changed fixture should read");
    assert!(pre.meta.media.is_none());
    assert!(pre.contact.affiliation.is_none());
    let post = pre.format_with(Some(changed));
    assert_eq!(
        post.meta
            .media
            .and_then(|media| media.first().cloned())
            .and_then(|media| media.content_url()),
        Some("42.png".to_string())
    );
    assert_eq!(post.contact.affiliation.as_deref(), Some("National Security Sciences Directorate"));
    let unresolved = ResearchActivity::read(fixture_path("data/format/unresolved_changes/index.json")).expect("unresolved fixture should read");
    assert!(unresolved.meta.media.is_some());
    assert_eq!(unresolved.contact.affiliation.as_deref(), Some("Not an actual affiliation"));
    let post = unresolved.format_with(None);
    assert!(post
        .meta
        .media
        .as_ref()
        .and_then(|media| media.first())
        .cloned()
        .and_then(MediaObject::content_url)
        .is_none());
    assert_eq!(
        post.meta
            .media
            .as_ref()
            .and_then(|media| media.first())
            .cloned()
            .map(MediaObject::description),
        Some(String::new())
    );
    assert_eq!(post.contact.affiliation.as_deref(), Some("Oak Ridge National Laboratory"));
    let mut pre = post.clone();
    pre.contact.organization = "Not an actual organization".to_string();
    pre.contact.affiliation = None;
    let post = pre.format_with(None);
    assert_eq!(post.contact.organization, String::new());
    assert_eq!(post.contact.affiliation.as_deref(), Some("Oak Ridge National Laboratory"));
    let mut pre = post.clone();
    pre.contact.organization = "Oak Ridge National Laboratory".to_string();
    pre.contact.affiliation = None;
    let post = pre.format_with(None);
    assert_eq!(post.contact.organization, "Oak Ridge National Laboratory");
    assert_eq!(post.contact.affiliation.as_deref(), Some("Oak Ridge National Laboratory"));
    let unchanged = fixture_path("data/format/no_changes");
    let pre = ResearchActivity::read(unchanged.join("index.json")).expect("unchanged fixture should read");
    assert_eq!(
        pre.meta
            .media
            .as_ref()
            .and_then(|media| media.first())
            .cloned()
            .and_then(MediaObject::content_url),
        Some("00.png".to_string())
    );
    let post = pre.format_with(Some(unchanged));
    assert_eq!(
        post.meta
            .media
            .as_ref()
            .and_then(|media| media.first())
            .cloned()
            .and_then(MediaObject::content_url),
        Some("42.png".to_string())
    );
    assert_eq!(post.contact.affiliation.as_deref(), Some("National Security Sciences Directorate"));
}
#[test]
fn test_text_input_output_rejects_unsupported_extension() {
    let directory = tempdir().expect("temporary directory should be created");
    assert!(Text::read(directory.path().join("text.pptx")).is_err());
}
#[test]
fn test_text_input_output_round_trip_txt() {
    let directory = tempdir().expect("temporary directory should be created");
    let path = directory.path().join("text.txt");
    let source = Text {
        content: "plain text content\nwith two lines".to_string(),
    };
    source.write(&path).expect("text should write");
    assert_eq!(Text::read(path).expect("text should read"), source);
}
#[test]
fn test_text_try_from_path_uses_supported_extensions() {
    let result = Text::read(fixture_path("gather/depth/direct.md")).expect("supported Markdown path should load");
    assert!(!result.content.is_empty());
    let directory = tempdir().expect("temporary directory should be created");
    assert!(Text::read(directory.path().join("text.pptx")).is_err());
}
#[test]
fn test_zon_rad_read_preserves_multiple_eserde_errors() {
    let directory = tempdir().expect("temporary directory should be created");
    let value: serde_json::Value = serde_json::from_slice(&read(fixture_path("malformed-rad.json")).unwrap()).unwrap();
    let path = directory.path().join("malformed.zonf");
    write(&path, zon::encode(&value).unwrap()).unwrap();
    let error = ResearchActivity::read_zon(path).expect_err("malformed ZON RAD should fail");
    let details = error.to_string();
    assert!(details.lines().count() >= 2, "expected multiple schema errors: {details}");
}