use crate::error::{OboError, Result};
use crate::patch::OboPatchOp;
use ontocore_core::{read_to_string_capped, MAX_FILE_BYTES};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchDiagnostic {
pub severity: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyPatchResult {
pub applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<PatchDiagnostic>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_path: Option<String>,
}
pub fn apply_patches(
document_path: &Path,
patches: &[OboPatchOp],
preview_only: bool,
) -> Result<ApplyPatchResult> {
let source = read_to_string_capped(document_path, MAX_FILE_BYTES).map_err(OboError::Core)?;
let mut result = apply_patches_to_text(&source, patches, preview_only)?;
result.document_path = Some(document_path.display().to_string());
if result.applied && !preview_only {
if let Some(text) = &result.preview_text {
atomic_write(document_path, text)?;
}
}
Ok(result)
}
pub fn apply_patches_to_text(
source: &str,
patches: &[OboPatchOp],
preview_only: bool,
) -> Result<ApplyPatchResult> {
let mut working = source.to_string();
let mut diagnostics = Vec::new();
for patch in patches {
if let Err(e) = apply_one(&mut working, patch) {
diagnostics
.push(PatchDiagnostic { severity: "error".to_string(), message: e.to_string() });
return Ok(ApplyPatchResult {
applied: false,
preview_text: Some(source.to_string()),
diagnostics,
document_path: None,
});
}
}
validate_obo(&working)?;
let changed = working != source;
Ok(ApplyPatchResult {
applied: changed && !preview_only,
preview_text: if changed { Some(working) } else { None },
diagnostics,
document_path: None,
})
}
fn validate_obo(text: &str) -> Result<()> {
fastobo::from_str(text).map_err(|e| OboError::Parse(e.to_string()))?;
Ok(())
}
fn apply_one(text: &mut String, patch: &OboPatchOp) -> Result<()> {
match patch {
OboPatchOp::SetName { term_id, value } => {
set_single_line(text, term_id, "name:", value, false)
}
OboPatchOp::AddSynonym { term_id, value, scope } => {
let line = format!("synonym: \"{value}\" {scope} []");
add_line_in_term(text, term_id, &line)
}
OboPatchOp::RemoveSynonym { term_id, value } => {
remove_lines_matching(text, term_id, &format!("synonym: \"{value}\""))
}
OboPatchOp::AddDef { term_id, value } => {
let escaped = value.replace('"', "\\\"");
let line = format!("def: \"{escaped}\" []");
set_or_add_line(text, term_id, "def:", &line)
}
OboPatchOp::RemoveDef { term_id } => remove_lines_matching(text, term_id, "def:"),
OboPatchOp::AddXref { term_id, xref } => {
let line = format!("xref: {xref}");
add_line_in_term(text, term_id, &line)
}
OboPatchOp::RemoveXref { term_id, xref } => {
remove_lines_matching(text, term_id, &format!("xref: {xref}"))
}
OboPatchOp::SetNamespace { term_id, namespace } => {
set_single_line(text, term_id, "namespace:", namespace, false)
}
OboPatchOp::SetDeprecated { term_id, value } => {
let val = if *value { "true" } else { "false" };
set_single_line(text, term_id, "is_obsolete:", val, false)
}
OboPatchOp::AddIsA { term_id, parent_id } => {
let line = format!("is_a: {parent_id} ! {parent_id}");
add_line_in_term(text, term_id, &line)
}
OboPatchOp::RemoveIsA { term_id, parent_id } => {
remove_lines_matching(text, term_id, &format!("is_a: {parent_id}"))
}
}
}
fn term_block_range(text: &str, term_id: &str) -> Result<(usize, usize)> {
let id_line = format!("id: {term_id}");
let start = text.find(&id_line).ok_or_else(|| OboError::TermNotFound(term_id.to_string()))?;
let block_start = text[..start].rfind("[Term]").unwrap_or(start);
let rest = &text[start..];
let next_term = rest[1..].find("\n[Term]").map(|i| start + 1 + i).unwrap_or(text.len());
Ok((block_start, next_term))
}
fn set_single_line(
text: &mut String,
term_id: &str,
prefix: &str,
value: &str,
_allow_multiple: bool,
) -> Result<()> {
let line = format!("{prefix} {value}");
let (start, end) = term_block_range(text, term_id)?;
let block = &text[start..end];
if let Some(idx) = block.lines().position(|l| l.trim_start().starts_with(prefix)) {
let lines: Vec<&str> = block.lines().collect();
let mut out = String::new();
for (i, l) in lines.iter().enumerate() {
if i == idx {
out.push_str(&line);
} else {
out.push_str(l);
}
out.push('\n');
}
text.replace_range(start..end, &out);
Ok(())
} else {
add_line_in_term(text, term_id, &line)
}
}
fn set_or_add_line(text: &mut String, term_id: &str, prefix: &str, full_line: &str) -> Result<()> {
let (start, end) = term_block_range(text, term_id)?;
let block = &text[start..end];
if block.lines().any(|l| l.trim_start().starts_with(prefix)) {
remove_lines_matching(text, term_id, prefix)?;
}
add_line_in_term(text, term_id, full_line)
}
fn add_line_in_term(text: &mut String, term_id: &str, line: &str) -> Result<()> {
let (start, end) = term_block_range(text, term_id)?;
let block = &text[start..end];
let mut new_block = block.trim_end().to_string();
if !new_block.ends_with('\n') {
new_block.push('\n');
}
new_block.push_str(line);
new_block.push('\n');
text.replace_range(start..end, &new_block);
Ok(())
}
fn remove_lines_matching(text: &mut String, term_id: &str, needle: &str) -> Result<()> {
let (start, end) = term_block_range(text, term_id)?;
let block = &text[start..end];
let filtered: String =
block.lines().filter(|l| !l.contains(needle)).collect::<Vec<_>>().join("\n");
let mut new_block = filtered;
if !new_block.ends_with('\n') {
new_block.push('\n');
}
text.replace_range(start..end, &new_block);
Ok(())
}
pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
let parent =
path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent).map_err(|e| OboError::Io(e.to_string()))?;
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("file");
let tmp_path = parent.join(format!(".ontocode-{stem}-{nanos}.tmp"));
{
let mut file = fs::File::create(&tmp_path).map_err(|e| OboError::Io(e.to_string()))?;
file.write_all(contents.as_bytes()).map_err(|e| OboError::Io(e.to_string()))?;
file.sync_all().map_err(|e| OboError::Io(e.to_string()))?;
}
fs::rename(&tmp_path, path).map_err(|e| OboError::Io(e.to_string()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"format-version: 1.2
ontology: ex
[Term]
id: EX:001
name: example
is_a: EX:000 ! root
[Term]
id: EX:002
name: other
"#;
#[test]
fn set_name_and_add_synonym() {
let result = apply_patches_to_text(
SAMPLE,
&[
OboPatchOp::SetName { term_id: "EX:001".into(), value: "renamed".into() },
OboPatchOp::AddSynonym {
term_id: "EX:001".into(),
value: "alias".into(),
scope: "EXACT".into(),
},
],
true,
)
.expect("patch");
let text = result.preview_text.expect("preview");
assert!(text.contains("name: renamed"));
assert!(text.contains("synonym: \"alias\" EXACT"));
}
#[test]
fn add_and_remove_is_a() {
let added = apply_patches_to_text(
SAMPLE,
&[OboPatchOp::AddIsA { term_id: "EX:002".into(), parent_id: "EX:001".into() }],
true,
)
.expect("add");
let with_parent = added.preview_text.expect("preview");
assert!(with_parent.contains("is_a: EX:001"));
let removed = apply_patches_to_text(
&with_parent,
&[OboPatchOp::RemoveIsA { term_id: "EX:002".into(), parent_id: "EX:001".into() }],
true,
)
.expect("remove");
let text = removed.preview_text.expect("preview");
assert!(!text.contains("EX:002") || !text.lines().any(|l| l.contains("is_a: EX:001")));
}
}