Skip to main content

strixonomy_obo/
apply.rs

1use crate::error::{OboError, Result};
2use crate::patch::OboPatchOp;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use strixonomy_core::{read_to_string_capped, MAX_FILE_BYTES};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct PatchDiagnostic {
9    pub severity: String,
10    pub message: String,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ApplyPatchResult {
15    pub applied: bool,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub preview_text: Option<String>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub diagnostics: Vec<PatchDiagnostic>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub document_path: Option<String>,
22}
23
24pub fn apply_patches(
25    document_path: &Path,
26    patches: &[OboPatchOp],
27    preview_only: bool,
28) -> Result<ApplyPatchResult> {
29    let source = read_to_string_capped(document_path, MAX_FILE_BYTES).map_err(OboError::Core)?;
30    let mut result = apply_patches_to_text(&source, patches, preview_only)?;
31    result.document_path = Some(document_path.display().to_string());
32    if result.applied && !preview_only {
33        if let Some(text) = &result.preview_text {
34            atomic_write(document_path, text)?;
35        }
36    }
37    Ok(result)
38}
39
40pub fn apply_patches_to_text(
41    source: &str,
42    patches: &[OboPatchOp],
43    preview_only: bool,
44) -> Result<ApplyPatchResult> {
45    let mut working = source.to_string();
46    let mut diagnostics = Vec::new();
47    for patch in patches {
48        if let Err(e) = apply_one(&mut working, patch) {
49            diagnostics
50                .push(PatchDiagnostic { severity: "error".to_string(), message: e.to_string() });
51            return Ok(ApplyPatchResult {
52                applied: false,
53                preview_text: Some(source.to_string()),
54                diagnostics,
55                document_path: None,
56            });
57        }
58    }
59    validate_obo(&working)?;
60    let changed = working != source;
61    Ok(ApplyPatchResult {
62        applied: changed && !preview_only,
63        preview_text: if changed { Some(working) } else { None },
64        diagnostics,
65        document_path: None,
66    })
67}
68
69fn validate_obo(text: &str) -> Result<()> {
70    fastobo::from_str(text).map_err(|e| OboError::Parse(e.to_string()))?;
71    Ok(())
72}
73
74/// Reject values that would break single-line OBO fields or inject extra stanzas.
75fn reject_line_breaks(value: &str, field: &str) -> Result<()> {
76    if value.contains('\n') || value.contains('\r') {
77        return Err(OboError::PatchInvalid(format!("{field} must not contain line breaks")));
78    }
79    Ok(())
80}
81
82/// Reject OBO token values (IDs, xrefs) that contain whitespace or line breaks.
83fn reject_obo_token(value: &str, field: &str) -> Result<()> {
84    reject_line_breaks(value, field)?;
85    if value.chars().any(char::is_whitespace) {
86        return Err(OboError::PatchInvalid(format!("{field} must not contain whitespace")));
87    }
88    Ok(())
89}
90
91fn validate_patch(patch: &OboPatchOp) -> Result<()> {
92    match patch {
93        OboPatchOp::SetName { term_id, value } => {
94            reject_obo_token(term_id, "term_id")?;
95            reject_line_breaks(value, "name")?;
96        }
97        OboPatchOp::AddSynonym { term_id, value, scope } => {
98            reject_obo_token(term_id, "term_id")?;
99            reject_line_breaks(value, "synonym")?;
100            reject_line_breaks(scope, "synonym scope")?;
101        }
102        OboPatchOp::RemoveSynonym { term_id, value, scope } => {
103            reject_obo_token(term_id, "term_id")?;
104            reject_line_breaks(value, "synonym")?;
105            if let Some(scope) = scope {
106                reject_line_breaks(scope, "synonym scope")?;
107            }
108        }
109        OboPatchOp::AddDef { term_id, value } => {
110            reject_obo_token(term_id, "term_id")?;
111            reject_line_breaks(value, "definition")?;
112        }
113        OboPatchOp::RemoveDef { term_id } => reject_obo_token(term_id, "term_id")?,
114        OboPatchOp::AddXref { term_id, xref } => {
115            reject_obo_token(term_id, "term_id")?;
116            reject_obo_token(xref, "xref")?;
117        }
118        OboPatchOp::RemoveXref { term_id, xref } => {
119            reject_obo_token(term_id, "term_id")?;
120            reject_obo_token(xref, "xref")?;
121        }
122        OboPatchOp::SetNamespace { term_id, namespace } => {
123            reject_obo_token(term_id, "term_id")?;
124            reject_line_breaks(namespace, "namespace")?;
125        }
126        OboPatchOp::SetDeprecated { term_id, .. } => reject_obo_token(term_id, "term_id")?,
127        OboPatchOp::AddIsA { term_id, parent_id } => {
128            reject_obo_token(term_id, "term_id")?;
129            reject_obo_token(parent_id, "parent_id")?;
130        }
131        OboPatchOp::RemoveIsA { term_id, parent_id } => {
132            reject_obo_token(term_id, "term_id")?;
133            reject_obo_token(parent_id, "parent_id")?;
134        }
135    }
136    Ok(())
137}
138
139fn apply_one(text: &mut String, patch: &OboPatchOp) -> Result<()> {
140    validate_patch(patch)?;
141    match patch {
142        OboPatchOp::SetName { term_id, value } => {
143            set_single_line(text, term_id, "name:", value, false)
144        }
145        OboPatchOp::AddSynonym { term_id, value, scope } => {
146            let escaped = value.replace('"', "\\\"");
147            let line = format!("synonym: \"{escaped}\" {scope} []");
148            add_line_in_term(text, term_id, &line)
149        }
150        OboPatchOp::RemoveSynonym { term_id, value, scope } => {
151            remove_synonym_line(text, term_id, value, scope.as_deref())
152        }
153        OboPatchOp::AddDef { term_id, value } => {
154            let escaped = value.replace('"', "\\\"");
155            let line = format!("def: \"{escaped}\" []");
156            set_or_add_line(text, term_id, "def:", &line)
157        }
158        OboPatchOp::RemoveDef { term_id } => remove_lines_with_prefix(text, term_id, "def:"),
159        OboPatchOp::AddXref { term_id, xref } => {
160            let line = format!("xref: {xref}");
161            add_line_in_term(text, term_id, &line)
162        }
163        OboPatchOp::RemoveXref { term_id, xref } => remove_xref_line(text, term_id, xref),
164        OboPatchOp::SetNamespace { term_id, namespace } => {
165            set_single_line(text, term_id, "namespace:", namespace, false)
166        }
167        OboPatchOp::SetDeprecated { term_id, value } => {
168            let val = if *value { "true" } else { "false" };
169            set_single_line(text, term_id, "is_obsolete:", val, false)
170        }
171        OboPatchOp::AddIsA { term_id, parent_id } => {
172            let line = format!("is_a: {parent_id} ! {parent_id}");
173            add_line_in_term(text, term_id, &line)
174        }
175        OboPatchOp::RemoveIsA { term_id, parent_id } => remove_is_a_line(text, term_id, parent_id),
176    }
177}
178
179fn term_block_range(text: &str, term_id: &str) -> Result<(usize, usize)> {
180    let id_line_start = find_term_id_line_start(text, term_id)
181        .ok_or_else(|| OboError::TermNotFound(term_id.to_string()))?;
182    let block_start = preceding_stanza_header(text, id_line_start).unwrap_or(id_line_start);
183    let block_end = next_stanza_offset(text, id_line_start).unwrap_or(text.len());
184    Ok((block_start, block_end))
185}
186
187/// Byte offset of the stanza header (`[Term]`, `[Typedef]`, `[Instance]`, …) that owns `before`.
188fn preceding_stanza_header(text: &str, before: usize) -> Option<usize> {
189    let mut last = None;
190    let mut offset = 0usize;
191    for line in text[..before].split_inclusive('\n') {
192        if is_obo_stanza_header(line.trim_end_matches(['\n', '\r'])) {
193            last = Some(offset);
194        }
195        offset += line.len();
196    }
197    last
198}
199
200/// Dominant newline sequence in `text` (`\r\n` if present, else `\n`).
201fn detect_newline(text: &str) -> &'static str {
202    if text.contains("\r\n") {
203        "\r\n"
204    } else {
205        "\n"
206    }
207}
208
209/// Byte offset of the next OBO stanza header (`[Term]`, `[Typedef]`, `[Instance]`, …)
210/// at or after `from`, or `None` if this is the last stanza.
211fn next_stanza_offset(text: &str, from: usize) -> Option<usize> {
212    let mut offset = from;
213    let mut skip_current_line = true;
214    for line in text[from..].split_inclusive('\n') {
215        if skip_current_line {
216            skip_current_line = false;
217            offset += line.len();
218            continue;
219        }
220        if is_obo_stanza_header(line.trim_end_matches(['\n', '\r'])) {
221            return Some(offset);
222        }
223        offset += line.len();
224    }
225    None
226}
227
228fn is_obo_stanza_header(line: &str) -> bool {
229    let trimmed = line.trim();
230    let Some(name) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
231        return false;
232    };
233    !name.is_empty() && name.chars().all(|c| c.is_ascii_alphabetic())
234}
235
236fn find_term_id_line_start(text: &str, term_id: &str) -> Option<usize> {
237    let mut offset = 0usize;
238    for line in text.split_inclusive('\n') {
239        let line_body = line.trim_end_matches(['\n', '\r']);
240        if obo_id_line_matches(line_body, term_id) {
241            return Some(offset);
242        }
243        offset += line.len();
244    }
245    None
246}
247
248fn obo_id_line_matches(line: &str, term_id: &str) -> bool {
249    let trimmed = line.trim_start();
250    if !trimmed.starts_with("id:") {
251        return false;
252    }
253    obo_field_token(trimmed["id:".len()..].trim_start()) == Some(term_id)
254}
255
256fn set_single_line(
257    text: &mut String,
258    term_id: &str,
259    prefix: &str,
260    value: &str,
261    _allow_multiple: bool,
262) -> Result<()> {
263    let nl = detect_newline(text);
264    let line = format!("{prefix} {value}");
265    let (start, end) = term_block_range(text, term_id)?;
266    let block = &text[start..end];
267    if let Some(idx) = block.lines().position(|l| l.trim_start().starts_with(prefix)) {
268        let lines: Vec<&str> = block.lines().collect();
269        let mut out = String::new();
270        for (i, l) in lines.iter().enumerate() {
271            if i == idx {
272                out.push_str(&line);
273            } else {
274                out.push_str(l);
275            }
276            out.push_str(nl);
277        }
278        text.replace_range(start..end, &out);
279        Ok(())
280    } else {
281        add_line_in_term(text, term_id, &line)
282    }
283}
284
285fn set_or_add_line(text: &mut String, term_id: &str, prefix: &str, full_line: &str) -> Result<()> {
286    let (start, end) = term_block_range(text, term_id)?;
287    let block = &text[start..end];
288    if block.lines().any(|l| l.trim_start().starts_with(prefix)) {
289        remove_lines_with_prefix(text, term_id, prefix)?;
290    }
291    add_line_in_term(text, term_id, full_line)
292}
293
294fn add_line_in_term(text: &mut String, term_id: &str, line: &str) -> Result<()> {
295    let nl = detect_newline(text);
296    let (start, end) = term_block_range(text, term_id)?;
297    let block = &text[start..end];
298    let mut new_block = block.trim_end_matches(['\n', '\r']).to_string();
299    if !new_block.is_empty() {
300        new_block.push_str(nl);
301    }
302    new_block.push_str(line);
303    new_block.push_str(nl);
304    text.replace_range(start..end, &new_block);
305    Ok(())
306}
307
308fn remove_lines_where(
309    text: &mut String,
310    term_id: &str,
311    should_remove: impl Fn(&str) -> bool,
312    not_found: Option<&str>,
313) -> Result<()> {
314    let nl = detect_newline(text);
315    let (start, end) = term_block_range(text, term_id)?;
316    let block = &text[start..end];
317    let lines: Vec<&str> = block.lines().collect();
318    let removed_any = lines.iter().any(|l| should_remove(l));
319    if let Some(message) = not_found {
320        if !removed_any {
321            return Err(OboError::PatchInvalid(message.to_string()));
322        }
323    }
324    let kept: Vec<&str> = lines.into_iter().filter(|l| !should_remove(l)).collect();
325    let mut new_block = kept.join(nl);
326    if !new_block.is_empty() {
327        new_block.push_str(nl);
328    }
329    text.replace_range(start..end, &new_block);
330    Ok(())
331}
332
333fn remove_lines_with_prefix(text: &mut String, term_id: &str, prefix: &str) -> Result<()> {
334    remove_lines_where(text, term_id, |l| l.trim_start().starts_with(prefix), None)
335}
336
337fn remove_is_a_line(text: &mut String, term_id: &str, parent_id: &str) -> Result<()> {
338    let parent_id = parent_id.to_string();
339    let not_found = format!("is_a parent not found: {parent_id}");
340    remove_lines_where(text, term_id, move |l| is_is_a_parent_line(l, &parent_id), Some(&not_found))
341}
342
343fn remove_xref_line(text: &mut String, term_id: &str, xref: &str) -> Result<()> {
344    let xref = xref.to_string();
345    let not_found = format!("xref not found: {xref}");
346    remove_lines_where(text, term_id, move |l| is_xref_line(l, &xref), Some(&not_found))
347}
348
349fn remove_synonym_line(
350    text: &mut String,
351    term_id: &str,
352    value: &str,
353    scope: Option<&str>,
354) -> Result<()> {
355    let (start, end) = term_block_range(text, term_id)?;
356    let block = &text[start..end];
357    let matches: Vec<&str> =
358        block.lines().filter(|l| is_synonym_match_line(l, value, scope)).collect();
359    if matches.is_empty() {
360        return Err(OboError::PatchInvalid(format!("synonym not found: {value}")));
361    }
362    if scope.is_none() && matches.len() > 1 {
363        return Err(OboError::PatchInvalid(format!(
364            "multiple synonyms with text {value:?}; specify scope to disambiguate"
365        )));
366    }
367    let value = value.to_string();
368    let scope = scope.map(str::to_string);
369    let not_found = format!("synonym not found: {value}");
370    remove_lines_where(
371        text,
372        term_id,
373        move |l| is_synonym_match_line(l, &value, scope.as_deref()),
374        Some(&not_found),
375    )
376}
377
378fn is_synonym_match_line(line: &str, value: &str, scope: Option<&str>) -> bool {
379    let Some((v, line_scope)) = parse_synonym_value_and_scope(line) else {
380        return false;
381    };
382    if v != value {
383        return false;
384    }
385    match scope {
386        Some(want) => line_scope.eq_ignore_ascii_case(want),
387        None => true,
388    }
389}
390
391fn parse_synonym_value_and_scope(line: &str) -> Option<(String, String)> {
392    let value = parse_quoted_value_after_prefix(line, "synonym:")?;
393    let trimmed = line.trim_start();
394    let after_prefix = trimmed.strip_prefix("synonym:")?.trim_start();
395    if !after_prefix.starts_with('"') {
396        return None;
397    }
398    // Walk past the quoted value (same unescape rules as parse_quoted_value_after_prefix).
399    let mut rest = &after_prefix[1..];
400    let mut chars = rest.chars();
401    while let Some(c) = chars.next() {
402        if c == '\\' {
403            let _ = chars.next()?;
404        } else if c == '"' {
405            rest = chars.as_str().trim_start();
406            break;
407        }
408    }
409    let scope_end =
410        rest.find(|c: char| c.is_whitespace() || c == '[' || c == '{').unwrap_or(rest.len());
411    let scope = rest[..scope_end].trim();
412    if scope.is_empty() {
413        return None;
414    }
415    Some((value, scope.to_string()))
416}
417
418fn obo_field_token(rest: &str) -> Option<&str> {
419    let end = rest.find(|c: char| c.is_whitespace() || c == '!' || c == '{').unwrap_or(rest.len());
420    let token = rest[..end].trim();
421    if token.is_empty() {
422        None
423    } else {
424        Some(token)
425    }
426}
427
428fn is_is_a_parent_line(line: &str, parent_id: &str) -> bool {
429    let trimmed = line.trim_start();
430    if !trimmed.starts_with("is_a:") {
431        return false;
432    }
433    obo_field_token(trimmed["is_a:".len()..].trim_start()) == Some(parent_id)
434}
435
436fn is_xref_line(line: &str, xref: &str) -> bool {
437    let trimmed = line.trim_start();
438    if !trimmed.starts_with("xref:") {
439        return false;
440    }
441    obo_field_token(trimmed["xref:".len()..].trim_start()) == Some(xref)
442}
443
444fn parse_quoted_value_after_prefix(line: &str, prefix: &str) -> Option<String> {
445    let trimmed = line.trim_start();
446    if !trimmed.starts_with(prefix) {
447        return None;
448    }
449    let mut rest = trimmed[prefix.len()..].trim_start();
450    if !rest.starts_with('"') {
451        return None;
452    }
453    rest = &rest[1..];
454    let mut out = String::new();
455    let mut chars = rest.chars();
456    while let Some(c) = chars.next() {
457        if c == '\\' {
458            out.push(chars.next()?);
459        } else if c == '"' {
460            return Some(out);
461        } else {
462            out.push(c);
463        }
464    }
465    None
466}
467
468/// Atomically replace `path` with `contents`, preserving existing permission bits (#422).
469pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
470    strixonomy_core::atomic_write(path, contents).map_err(|e| OboError::Io(e.to_string()))
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    const SAMPLE: &str = r#"format-version: 1.2
478ontology: ex
479
480[Term]
481id: EX:001
482name: example
483is_a: EX:000 ! root
484
485[Term]
486id: EX:002
487name: other
488"#;
489
490    #[test]
491    fn set_name_and_add_synonym() {
492        let result = apply_patches_to_text(
493            SAMPLE,
494            &[
495                OboPatchOp::SetName { term_id: "EX:001".into(), value: "renamed".into() },
496                OboPatchOp::AddSynonym {
497                    term_id: "EX:001".into(),
498                    value: "alias".into(),
499                    scope: "EXACT".into(),
500                },
501            ],
502            true,
503        )
504        .expect("patch");
505        let text = result.preview_text.expect("preview");
506        assert!(text.contains("name: renamed"));
507        assert!(text.contains("synonym: \"alias\" EXACT"));
508    }
509
510    #[test]
511    fn add_and_remove_is_a() {
512        let added = apply_patches_to_text(
513            SAMPLE,
514            &[OboPatchOp::AddIsA { term_id: "EX:002".into(), parent_id: "EX:001".into() }],
515            true,
516        )
517        .expect("add");
518        let with_parent = added.preview_text.expect("preview");
519        assert!(with_parent.contains("is_a: EX:001"));
520
521        let removed = apply_patches_to_text(
522            &with_parent,
523            &[OboPatchOp::RemoveIsA { term_id: "EX:002".into(), parent_id: "EX:001".into() }],
524            true,
525        )
526        .expect("remove");
527        let text = removed.preview_text.expect("preview");
528        let (start, end) = term_block_range(&text, "EX:002").expect("term block");
529        assert!(
530            !text[start..end].lines().any(|l| is_is_a_parent_line(l, "EX:001")),
531            "EX:001 parent must be removed from EX:002"
532        );
533    }
534
535    #[test]
536    fn remove_is_a_does_not_match_prefix_collision_parent_ids() {
537        const COLLISION: &str = r#"format-version: 1.2
538ontology: ex
539
540[Term]
541id: EX:002
542name: child
543is_a: EX:0010 ! longer parent
544is_a: EX:001 ! shorter parent
545"#;
546
547        let result = apply_patches_to_text(
548            COLLISION,
549            &[OboPatchOp::RemoveIsA { term_id: "EX:002".into(), parent_id: "EX:001".into() }],
550            true,
551        )
552        .expect("remove shorter parent only");
553
554        let text = result.preview_text.expect("preview");
555        assert!(
556            text.lines().any(|l| l.contains("is_a: EX:0010")),
557            "EX:0010 parent must remain when removing EX:001"
558        );
559        assert!(
560            !text.lines().any(|l| is_is_a_parent_line(l, "EX:001")),
561            "EX:001 parent must be removed"
562        );
563    }
564
565    #[test]
566    fn set_name_targets_exact_term_id_not_prefix() {
567        const COLLISION: &str = r#"format-version: 1.2
568ontology: test
569
570[Term]
571id: GO:00000010
572name: ten
573
574[Term]
575id: GO:0000001
576name: one
577"#;
578
579        let result = apply_patches_to_text(
580            COLLISION,
581            &[OboPatchOp::SetName { term_id: "GO:0000001".into(), value: "one renamed".into() }],
582            true,
583        )
584        .expect("patch shorter id");
585
586        let text = result.preview_text.expect("preview");
587        let (short_start, short_end) = term_block_range(&text, "GO:0000001").expect("short term");
588        let (long_start, long_end) = term_block_range(&text, "GO:00000010").expect("long term");
589        assert!(text[short_start..short_end].contains("name: one renamed"));
590        assert!(text[long_start..long_end].contains("name: ten"));
591        assert!(!text[long_start..long_end].contains("one renamed"));
592    }
593
594    #[test]
595    fn term_block_range_stops_before_typedef_stanza() {
596        const MIXED: &str = r#"format-version: 1.2
597ontology: ex
598
599[Term]
600id: EX:001
601name: A
602
603[Typedef]
604id: EX:rel
605name: related
606
607[Term]
608id: EX:002
609name: B
610"#;
611
612        let (start, end) = term_block_range(MIXED, "EX:001").expect("term block");
613        let block = &MIXED[start..end];
614        assert!(block.contains("id: EX:001"));
615        assert!(block.contains("name: A"));
616        assert!(
617            !block.contains("[Typedef]"),
618            "Typedef must not be part of the preceding Term block: {block}"
619        );
620        assert!(!block.contains("EX:rel"));
621        assert!(!block.contains("id: EX:002"));
622    }
623
624    #[test]
625    fn set_name_preserves_intervening_typedef() {
626        const MIXED: &str = r#"format-version: 1.2
627ontology: ex
628
629[Term]
630id: EX:001
631name: A
632
633[Typedef]
634id: EX:rel
635name: related
636
637[Term]
638id: EX:002
639name: B
640"#;
641
642        let result = apply_patches_to_text(
643            MIXED,
644            &[OboPatchOp::SetName { term_id: "EX:001".into(), value: "A renamed".into() }],
645            true,
646        )
647        .expect("set name");
648        let text = result.preview_text.expect("preview");
649        assert!(text.contains("name: A renamed"));
650        assert!(
651            text.contains("[Typedef]\nid: EX:rel\nname: related"),
652            "Typedef stanza must survive term edit: {text}"
653        );
654        assert!(text.contains("id: EX:002\nname: B"));
655    }
656
657    #[test]
658    fn add_synonym_preserves_intervening_instance_stanza() {
659        const MIXED: &str = r#"format-version: 1.2
660ontology: ex
661
662[Term]
663id: EX:001
664name: A
665
666[Instance]
667id: EX:inst
668name: sample
669
670[Term]
671id: EX:002
672name: B
673"#;
674
675        let result = apply_patches_to_text(
676            MIXED,
677            &[OboPatchOp::AddSynonym {
678                term_id: "EX:001".into(),
679                value: "alias".into(),
680                scope: "EXACT".into(),
681            }],
682            true,
683        )
684        .expect("add synonym");
685        let text = result.preview_text.expect("preview");
686        assert!(text.contains("synonym: \"alias\" EXACT"));
687        assert!(
688            text.contains("[Instance]\nid: EX:inst\nname: sample"),
689            "Instance stanza must survive term edit: {text}"
690        );
691    }
692
693    #[test]
694    fn remove_is_a_errors_when_parent_missing() {
695        let err = apply_patches_to_text(
696            SAMPLE,
697            &[OboPatchOp::RemoveIsA { term_id: "EX:001".into(), parent_id: "EX:999".into() }],
698            true,
699        )
700        .expect("patch result");
701        assert!(!err.diagnostics.is_empty());
702        assert!(err.diagnostics[0].message.contains("is_a parent not found"));
703    }
704
705    #[test]
706    fn rejects_set_name_with_embedded_newline() {
707        let result = apply_patches_to_text(
708            SAMPLE,
709            &[OboPatchOp::SetName {
710                term_id: "EX:001".into(),
711                value: "evil\nis_a: ATTACK:001".into(),
712            }],
713            true,
714        )
715        .expect("patch result");
716        assert!(!result.applied);
717        assert!(result.diagnostics.iter().any(|d| d.message.contains("line breaks")));
718        assert_eq!(result.preview_text.as_deref(), Some(SAMPLE));
719    }
720
721    #[test]
722    fn rejects_add_xref_with_embedded_newline() {
723        let result = apply_patches_to_text(
724            SAMPLE,
725            &[OboPatchOp::AddXref {
726                term_id: "EX:001".into(),
727                xref: "FOO:1\nname: injected".into(),
728            }],
729            true,
730        )
731        .expect("patch result");
732        assert!(!result.applied);
733        assert!(result.diagnostics.iter().any(|d| d.message.contains("line breaks")));
734    }
735
736    #[test]
737    fn rejects_add_is_a_with_whitespace_in_parent_id() {
738        let result = apply_patches_to_text(
739            SAMPLE,
740            &[OboPatchOp::AddIsA { term_id: "EX:002".into(), parent_id: "EX:001 injected".into() }],
741            true,
742        )
743        .expect("patch result");
744        assert!(!result.applied);
745        assert!(result.diagnostics.iter().any(|d| d.message.contains("whitespace")));
746    }
747
748    #[test]
749    fn atomic_write_overwrites_existing_file() {
750        let dir = tempfile::tempdir().unwrap();
751        let path = dir.path().join("term.obo");
752        std::fs::write(&path, "format-version: 1.2\nold\n").unwrap();
753        atomic_write(&path, "format-version: 1.2\nontology: new\n").expect("atomic write");
754        let contents = std::fs::read_to_string(&path).unwrap();
755        assert!(contents.contains("ontology: new"));
756        assert!(!contents.contains("old"));
757        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
758            .unwrap()
759            .filter_map(|e| e.ok())
760            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
761            .collect();
762        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
763    }
764
765    #[cfg(unix)]
766    #[test]
767    fn atomic_write_preserves_unix_mode() {
768        use std::os::unix::fs::PermissionsExt;
769        let dir = tempfile::tempdir().unwrap();
770        let path = dir.path().join("secret.obo");
771        std::fs::write(&path, "format-version: 1.2\nold\n").unwrap();
772        let mut perms = std::fs::metadata(&path).unwrap().permissions();
773        perms.set_mode(0o600);
774        std::fs::set_permissions(&path, perms).unwrap();
775        atomic_write(&path, "format-version: 1.2\nontology: private\n").expect("atomic write");
776        assert_eq!(
777            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
778            0o600,
779            "mode must survive OBO atomic write (#422)"
780        );
781    }
782
783    #[test]
784    fn set_name_on_typedef_does_not_rewrite_preceding_term() {
785        const MIXED: &str = r#"format-version: 1.2
786ontology: ex
787
788[Term]
789id: EX:001
790name: A
791
792[Typedef]
793id: EX:rel
794name: related
795"#;
796
797        let result = apply_patches_to_text(
798            MIXED,
799            &[OboPatchOp::SetName { term_id: "EX:rel".into(), value: "renamed".into() }],
800            true,
801        )
802        .expect("set typedef name");
803        let text = result.preview_text.expect("preview");
804        let (term_start, term_end) = term_block_range(&text, "EX:001").expect("term");
805        let (td_start, td_end) = term_block_range(&text, "EX:rel").expect("typedef");
806        assert!(text[term_start..term_end].contains("name: A"));
807        assert!(!text[term_start..term_end].contains("renamed"));
808        assert!(text[td_start..td_end].contains("name: renamed"));
809        assert!(!text[td_start..td_end].contains("name: related"));
810    }
811
812    #[test]
813    fn remove_synonym_requires_scope_when_text_is_ambiguous() {
814        const MULTI: &str = r#"format-version: 1.2
815ontology: ex
816
817[Term]
818id: EX:1
819name: t
820synonym: "foo" EXACT []
821synonym: "foo" BROAD []
822"#;
823
824        let ambiguous = apply_patches_to_text(
825            MULTI,
826            &[OboPatchOp::RemoveSynonym {
827                term_id: "EX:1".into(),
828                value: "foo".into(),
829                scope: None,
830            }],
831            true,
832        )
833        .expect("patch result");
834        assert!(!ambiguous.applied);
835        assert!(ambiguous.diagnostics.iter().any(|d| d.message.contains("multiple synonyms")));
836
837        let scoped = apply_patches_to_text(
838            MULTI,
839            &[OboPatchOp::RemoveSynonym {
840                term_id: "EX:1".into(),
841                value: "foo".into(),
842                scope: Some("EXACT".into()),
843            }],
844            true,
845        )
846        .expect("scoped remove");
847        let text = scoped.preview_text.expect("preview");
848        assert!(!text.contains(r#"synonym: "foo" EXACT"#));
849        assert!(text.contains(r#"synonym: "foo" BROAD"#));
850    }
851
852    #[test]
853    fn set_name_preserves_crlf_line_endings() {
854        let crlf = SAMPLE.replace('\n', "\r\n");
855        let result = apply_patches_to_text(
856            &crlf,
857            &[OboPatchOp::SetName { term_id: "EX:001".into(), value: "renamed".into() }],
858            true,
859        )
860        .expect("patch");
861        let text = result.preview_text.expect("preview");
862        assert!(text.contains("name: renamed\r\n"));
863        assert!(text.contains("\r\n"), "must keep CRLF");
864        assert!(!text.replace("\r\n", "").contains('\r'));
865        // Rewritten region should not introduce bare LF joins.
866        assert!(
867            !text.contains("renamed\nname:") && text.contains("renamed\r\n"),
868            "name line must end with CRLF"
869        );
870    }
871
872    #[test]
873    fn add_synonym_escapes_embedded_quotes() {
874        let result = apply_patches_to_text(
875            SAMPLE,
876            &[OboPatchOp::AddSynonym {
877                term_id: "EX:001".into(),
878                value: r#"foo "bar""#.into(),
879                scope: "EXACT".into(),
880            }],
881            true,
882        )
883        .expect("add synonym");
884        let text = result.preview_text.expect("preview");
885        assert!(text.contains(r#"synonym: "foo \"bar\"" EXACT []"#));
886    }
887}