use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::error::MifRhError;
use crate::harness_project::{read_json, validate_against_schema};
fn has_provenance(finding: &Value) -> bool {
finding
.pointer("/provenance/sourceType")
.is_some_and(|v| !v.is_null())
}
#[derive(Debug, Clone)]
pub struct ImportReport {
pub imported: usize,
pub namespace: String,
pub topic_registered: bool,
}
pub fn import_corpus(
src_findings_dir: &Path,
dest_dir: &Path,
topic: &str,
config_path: Option<&Path>,
schema_path: &Path,
ref_paths: &[PathBuf],
) -> Result<ImportReport, MifRhError> {
let mut paths: Vec<_> = std::fs::read_dir(src_findings_dir)
.map_err(|source| MifRhError::Io {
path: src_findings_dir.display().to_string(),
source,
})?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect();
paths.sort();
if paths.is_empty() {
return Err(MifRhError::NoFindingsFound {
path: src_findings_dir.display().to_string(),
});
}
let mut findings = Vec::with_capacity(paths.len());
let mut missing_provenance = Vec::new();
for path in &paths {
let finding = read_json(path)?;
validate_against_schema(&finding, path, schema_path, ref_paths)?;
if !has_provenance(&finding) {
missing_provenance.push(path.display().to_string());
}
findings.push(finding);
}
if !missing_provenance.is_empty() {
return Err(MifRhError::MissingProvenance {
count: missing_provenance.len(),
paths: missing_provenance,
});
}
std::fs::create_dir_all(dest_dir).map_err(|source| MifRhError::Io {
path: dest_dir.display().to_string(),
source,
})?;
for path in &paths {
let dest = dest_dir.join(path.file_name().unwrap_or_default());
std::fs::copy(path, &dest).map_err(|source| MifRhError::Io {
path: dest.display().to_string(),
source,
})?;
}
let namespace = findings[0]
.get("namespace")
.and_then(Value::as_str)
.map_or_else(|| format!("harness/{topic}"), str::to_string);
let topic_registered = if let Some(config_path) = config_path {
register_topic(config_path, topic, &namespace)?
} else {
false
};
Ok(ImportReport {
imported: findings.len(),
namespace,
topic_registered,
})
}
fn register_topic(config_path: &Path, topic: &str, namespace: &str) -> Result<bool, MifRhError> {
if !config_path.is_file() {
return Ok(false);
}
let mut config = read_json(config_path)?;
let already_registered = config
.get("topics")
.and_then(Value::as_array)
.is_some_and(|topics| {
topics
.iter()
.any(|t| t.get("id").and_then(Value::as_str) == Some(topic))
});
if already_registered {
return Ok(false);
}
let topics = config
.as_object_mut()
.map(|object| {
object
.entry("topics")
.or_insert_with(|| serde_json::json!([]))
})
.and_then(Value::as_array_mut)
.ok_or_else(|| MifRhError::ConfigMalformed {
path: config_path.display().to_string(),
detail: ".topics is not an array".to_string(),
})?;
topics.push(serde_json::json!({
"id": topic,
"title": topic,
"namespace": namespace,
"status": "active",
}));
let text =
serde_json::to_string_pretty(&config).map_err(|source| MifRhError::JsonSerialize {
path: config_path.display().to_string(),
source,
})?;
std::fs::write(config_path, format!("{text}\n")).map_err(|source| MifRhError::Io {
path: config_path.display().to_string(),
source,
})?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::import_corpus;
use std::fs;
const FINDINGS_SCHEMA: &str = r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["@id", "provenance"],
"properties": {
"@id": {"type": "string"},
"provenance": {"type": "object"}
}
}"#;
fn write_finding(dir: &std::path::Path, name: &str, contents: &str) {
fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn imports_valid_findings_with_provenance() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/findings");
fs::create_dir_all(&src).unwrap();
write_finding(
&src,
"f1.json",
r#"{"@id": "urn:mif:f1", "namespace": "harness/x", "provenance": {"sourceType": "agent_inferred"}}"#,
);
let schema_path = dir.path().join("findings.schema.json");
fs::write(&schema_path, FINDINGS_SCHEMA).unwrap();
let dest = dir.path().join("reports/topic/findings");
let report = import_corpus(&src, &dest, "topic", None, &schema_path, &[]).unwrap();
assert_eq!(report.imported, 1);
assert_eq!(report.namespace, "harness/x");
assert!(dest.join("f1.json").is_file());
}
#[test]
fn rejects_and_copies_nothing_when_a_finding_lacks_provenance() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/findings");
fs::create_dir_all(&src).unwrap();
write_finding(
&src,
"f1.json",
r#"{"@id": "urn:mif:f1", "provenance": {"sourceType": "x"}}"#,
);
write_finding(
&src,
"f2.json",
r#"{"@id": "urn:mif:f2", "provenance": {}}"#,
);
let schema_path = dir.path().join("findings.schema.json");
fs::write(&schema_path, FINDINGS_SCHEMA).unwrap();
let dest = dir.path().join("reports/topic/findings");
let error = import_corpus(&src, &dest, "topic", None, &schema_path, &[]).unwrap_err();
assert!(matches!(
error,
super::MifRhError::MissingProvenance { count: 1, .. }
));
assert!(!dest.exists() || fs::read_dir(&dest).unwrap().next().is_none());
}
#[test]
fn rejects_a_finding_that_fails_schema_validation() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/findings");
fs::create_dir_all(&src).unwrap();
write_finding(&src, "f1.json", r#"{"provenance": {"sourceType": "x"}}"#);
let schema_path = dir.path().join("findings.schema.json");
fs::write(&schema_path, FINDINGS_SCHEMA).unwrap();
let dest = dir.path().join("reports/topic/findings");
let error = import_corpus(&src, &dest, "topic", None, &schema_path, &[]).unwrap_err();
assert!(matches!(
error,
super::MifRhError::SchemaValidationFailed { .. }
));
}
#[test]
fn registers_a_new_topic_in_the_config() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/findings");
fs::create_dir_all(&src).unwrap();
write_finding(
&src,
"f1.json",
r#"{"@id": "urn:mif:f1", "namespace": "harness/x", "provenance": {"sourceType": "y"}}"#,
);
let schema_path = dir.path().join("findings.schema.json");
fs::write(&schema_path, FINDINGS_SCHEMA).unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(&config_path, r#"{"version": "1.0.0", "topics": []}"#).unwrap();
let dest = dir.path().join("reports/topic/findings");
let report = import_corpus(
&src,
&dest,
"new-topic",
Some(&config_path),
&schema_path,
&[],
)
.unwrap();
assert!(report.topic_registered);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(config["topics"][0]["id"], "new-topic");
assert_eq!(config["topics"][0]["namespace"], "harness/x");
}
#[test]
fn does_not_re_register_an_already_registered_topic() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/findings");
fs::create_dir_all(&src).unwrap();
write_finding(
&src,
"f1.json",
r#"{"@id": "urn:mif:f1", "provenance": {"sourceType": "y"}}"#,
);
let schema_path = dir.path().join("findings.schema.json");
fs::write(&schema_path, FINDINGS_SCHEMA).unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(
&config_path,
r#"{"version": "1.0.0", "topics": [{"id": "existing", "title": "Existing", "namespace": "harness/existing", "status": "active"}]}"#,
)
.unwrap();
let dest = dir.path().join("reports/topic/findings");
let report = import_corpus(
&src,
&dest,
"existing",
Some(&config_path),
&schema_path,
&[],
)
.unwrap();
assert!(!report.topic_registered);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(config["topics"].as_array().unwrap().len(), 1);
}
}