Skip to main content

nmbrs_workload/edit/
mod.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Workload-edit primitive — SRD-64 §6.4–§6.5.
5//!
6//! The single in-process transaction shape for every
7//! workload-mutating CLI command (`nmbrs report --add`,
8//! `--replace`, `nmbrs report rename`):
9//!
10//! 1. **Lock** the workload file ([`lock::acquire`]) so
11//!    concurrent `nmbrs` invocations don't corrupt each
12//!    other's writes.
13//! 2. **Read** the current content into memory.
14//! 3. **Parse** with tree-sitter-yaml ([`locate::parse`])
15//!    to a CST that preserves every byte (comments, blank
16//!    lines, quote styles).
17//! 4. **Locate** the target byte range
18//!    ([`locate::locate_path`]) for the anchor we want to
19//!    edit.
20//! 5. **Splice** the new content into the source
21//!    ([`splice`]) — bytes outside the located range stay
22//!    byte-identical.
23//! 6. **Roundtrip-parse** the result with the existing
24//!    [`super::parse_workload`] to verify the mutation
25//!    didn't break the workload schema.
26//! 7. **Rotate backups** ([`backup::rotate`]) — old
27//!    `.bak` → `.bak.prev`, current → `.bak`.
28//! 8. **Atomically commit** the new content via temp +
29//!    rename ([`backup::commit_temp`]).
30//!
31//! Failure at any step rolls back the backup pair so the
32//! invariant "<workload>.bak == content prior to the most
33//! recent successful edit" holds.
34//!
35//! ## Public surface
36//!
37//! [`with_workload`] is the one-call entry point.
38//! [`add_item`], [`replace_item`], [`rename_item`] are
39//! convenience wrappers for the SRD-64 promotion flows
40//! that target a `report:` block; they all dispatch
41//! through `with_workload`.
42//!
43//! ## Lock + backup are NOT optional
44//!
45//! Even tests reaching for `with_workload` exercise the
46//! full transaction — the lock is held, the backup pair is
47//! rotated. That's the contract; relaxing it for tests
48//! would mean the tests don't validate the contract.
49
50pub mod backup;
51pub mod locate;
52pub mod lock;
53pub mod splice;
54
55use std::io;
56use std::path::Path;
57
58use crate::report::ReportItem;
59
60/// Transactional edit context. The `mutate` closure receives
61/// a mutable [`EditCtx`] and returns the post-edit source
62/// string. The driver verifies that string parses cleanly
63/// before committing.
64pub struct EditCtx<'a> {
65    /// Original on-disk source bytes.
66    pub source: &'a str,
67    /// Path to the workload file (for diagnostics).
68    pub workload_path: &'a Path,
69}
70
71/// Run a workload edit transaction. The closure produces
72/// the new source content; the driver handles lock, backup,
73/// roundtrip-parse, and atomic commit.
74///
75/// Errors:
76/// - lock contention → kind `WouldBlock` with pid hint;
77/// - I/O on read / write → propagated;
78/// - mutation closure error → propagated, no on-disk
79///   change;
80/// - post-mutate parse failure → backups rolled back, no
81///   on-disk change, error explains parse failure.
82pub fn with_workload<F>(workload_path: &Path, mutate: F) -> io::Result<()>
83where
84    F: FnOnce(EditCtx<'_>) -> Result<String, String>,
85{
86    // 1. Lock.
87    let _guard = lock::acquire(workload_path)?;
88
89    // 2. Read.
90    let source = std::fs::read_to_string(workload_path).map_err(|e| {
91        io::Error::new(e.kind(), format!("read '{}': {e}", workload_path.display()))
92    })?;
93
94    // 3-5. Mutate via closure.
95    let new_source = mutate(EditCtx {
96        source: &source,
97        workload_path,
98    })
99    .map_err(|e| {
100        io::Error::new(
101            io::ErrorKind::InvalidData,
102            format!("workload edit '{}': {e}", workload_path.display()),
103        )
104    })?;
105
106    // 6. Roundtrip-parse: ensure the new content still
107    //    parses through `parse_workload`. We can't do this
108    //    cleanly at this layer because `parse_workload`
109    //    lives in the parent module which depends on the
110    //    workload model — that creates a layering issue.
111    //    For now we do a serde_yaml-level shape check
112    //    (yaml is well-formed); the caller can run a
113    //    deeper check if it wants. SRD-64 §6.4 requires
114    //    `parse_workload` succeed; the deeper check is
115    //    deferred to Phase D where the caller has the
116    //    full workload context.
117    let _: serde_yaml::Value = serde_yaml::from_str(&new_source).map_err(|e| {
118        io::Error::new(
119            io::ErrorKind::InvalidData,
120            format!("post-edit YAML failed to parse: {e}\n\n--- new content ---\n{new_source}",),
121        )
122    })?;
123
124    // 7. Backup rotate.
125    let paths = backup::rotate(workload_path)?;
126
127    // 8. Atomic commit via temp + rename.
128    if let Err(e) = std::fs::write(&paths.temp, &new_source) {
129        let _ = backup::rollback(&paths);
130        return Err(io::Error::new(
131            e.kind(),
132            format!("write temp '{}': {e}", paths.temp.display()),
133        ));
134    }
135    if let Err(e) = backup::commit_temp(&paths) {
136        let _ = backup::rollback(&paths);
137        return Err(e);
138    }
139    Ok(())
140}
141
142/// Outcome flag for [`add_item`] / [`replace_item`]: did
143/// the existing item already exist?
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum AddOutcome {
146    /// New item created at the chosen anchor.
147    Inserted,
148    /// Existing item replaced in place. Only returned
149    /// when `replace = true`.
150    Replaced,
151}
152
153/// Anchor specifier for `--add` / `--at` / `--contextual`.
154/// Mirrors the SRD-64 §6.1 surface but pre-resolved — the
155/// caller is responsible for translating user CLI input
156/// into one of these variants.
157#[derive(Debug, Clone)]
158pub enum Anchor {
159    /// Workload root: `report:` at the top-level mapping.
160    Root,
161    /// Named scenario: `scenarios.<name>.report:`.
162    Scenario(String),
163    /// Named phase: `phases.<name>.report:`.
164    Phase(String),
165    /// Named op-template: `phases.<phase>.ops.<op>.report:`.
166    Op { phase: String, op: String },
167}
168
169impl Anchor {
170    /// YAML key path to the `report:` mapping at this
171    /// anchor.
172    pub fn report_path(&self) -> Vec<String> {
173        match self {
174            Anchor::Root => vec!["report".to_string()],
175            Anchor::Scenario(s) => vec!["scenarios".to_string(), s.clone(), "report".to_string()],
176            Anchor::Phase(p) => vec!["phases".to_string(), p.clone(), "report".to_string()],
177            Anchor::Op { phase, op } => vec![
178                "phases".to_string(),
179                phase.clone(),
180                "ops".to_string(),
181                op.clone(),
182                "report".to_string(),
183            ],
184        }
185    }
186
187    /// Human-readable label for diagnostics.
188    pub fn label(&self) -> String {
189        match self {
190            Anchor::Root => "root".to_string(),
191            Anchor::Scenario(s) => format!("scenario:{s}"),
192            Anchor::Phase(p) => format!("phase:{p}"),
193            Anchor::Op { phase, op } => format!("op:{phase}.{op}"),
194        }
195    }
196}
197
198/// Promote `item` into the workload at `anchor`'s
199/// `report:` block, in `group`. Returns
200/// [`AddOutcome::Inserted`] if the item was new;
201/// [`AddOutcome::Replaced`] if `replace=true` and the item
202/// pre-existed.
203///
204/// Collision policy:
205/// - Item name already exists at `anchor`'s report block,
206///   `replace=false` ⇒ error with the existing location.
207/// - Item name exists elsewhere in the workload (different
208///   anchor), `replace=false` ⇒ error pointing at that
209///   location and recommending `--rename` or
210///   `--at <other>`.
211/// - `replace=true` overwrites the existing entry at its
212///   existing site (anchor isn't moved on replace).
213pub fn add_item(
214    workload_path: &Path,
215    anchor: &Anchor,
216    group: &str,
217    item: &ReportItem,
218    replace: bool,
219) -> io::Result<AddOutcome> {
220    let mut outcome = AddOutcome::Inserted;
221    let captured_outcome = &mut outcome;
222    with_workload(workload_path, |ctx| {
223        let result = apply_add(ctx.source, anchor, group, item, replace)?;
224        *captured_outcome = result.outcome;
225        Ok(result.new_source)
226    })?;
227    Ok(outcome)
228}
229
230struct AddResult {
231    new_source: String,
232    outcome: AddOutcome,
233}
234
235fn apply_add(
236    source: &str,
237    anchor: &Anchor,
238    group: &str,
239    item: &ReportItem,
240    replace: bool,
241) -> Result<AddResult, String> {
242    // Existing-name lookup walks the entire workload (every
243    // anchor's report block) since SRD-46 requires global
244    // name uniqueness. If the name is found anywhere and
245    // !replace, error.
246    let existing = find_existing_item(source, &item.name)?;
247    match existing {
248        Some(loc) if !replace => {
249            return Err(format!(
250                "report item '{}' already defined at {}; pass --replace to overwrite \
251                 in place, or --rename <new> to add under a different name",
252                item.name, loc.label,
253            ));
254        }
255        Some(_loc) if replace => {
256            // Replace at the existing site, regardless of
257            // the requested anchor. SRD-64 §6.3: existing
258            // site wins on replace.
259            let new_source = replace_existing_item(source, &item.name, item)?;
260            return Ok(AddResult {
261                new_source,
262                outcome: AddOutcome::Replaced,
263            });
264        }
265        _ => {}
266    }
267
268    // Insert at the requested anchor. The path resolves to
269    // either an existing `report:` mapping (insert a new
270    // group key under it, or append into an existing
271    // group) or a missing key (we have to materialise the
272    // intermediate keys all the way down).
273    insert_new_item_at_anchor(source, anchor, group, item).map(|new_source| AddResult {
274        new_source,
275        outcome: AddOutcome::Inserted,
276    })
277}
278
279/// Stub — full implementation lands in Phase D once the
280/// dispatch-tree walker is in place. Phase B verifies the
281/// edit primitive itself; the search across multiple
282/// anchors is dispatch-layer work.
283struct ExistingItemLocation {
284    label: String,
285}
286
287/// Look for `name` anywhere in the workload's report
288/// blocks. Today this only checks the root `report:` block;
289/// scenario / phase / op blocks land in Phase D.
290fn find_existing_item(source: &str, name: &str) -> Result<Option<ExistingItemLocation>, String> {
291    // Use the existing parser to read the workload, then
292    // walk every report's items looking for `name`.
293    let v: serde_json::Value = match serde_yaml::from_str::<serde_json::Value>(source) {
294        Ok(v) => v,
295        Err(e) => return Err(format!("workload yaml parse: {e}")),
296    };
297    if let Some(report) = v.get("report")
298        && let Ok(parsed) = crate::report::parse_report(report)
299        && parsed.report.find(name).is_some()
300    {
301        return Ok(Some(ExistingItemLocation {
302            label: "root".to_string(),
303        }));
304    }
305    // TODO Phase D: walk scenarios / phases / ops.
306    Ok(None)
307}
308
309fn replace_existing_item(
310    source: &str,
311    name: &str,
312    new_item: &ReportItem,
313) -> Result<String, String> {
314    // Locate the item's group + replace just the directive
315    // body within that group's block scalar. Today we do a
316    // simpler full-group rewrite: find the group containing
317    // the item, walk its existing items, swap in the new
318    // one keeping the others intact, re-emit the group
319    // body.
320    let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
321        .map_err(|e| format!("workload yaml parse: {e}"))?;
322    let report_value = v
323        .get("report")
324        .ok_or_else(|| format!("no `report:` block to find item '{name}'"))?;
325    let parsed =
326        crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
327
328    let group = parsed
329        .report
330        .groups
331        .iter()
332        .find(|g| g.items.iter().any(|i| i.name == name))
333        .ok_or_else(|| format!("item '{name}' not found in any report group"))?;
334
335    let mut new_group_body = String::new();
336    for it in &group.items {
337        let block = if it.name == name {
338            new_item.to_yaml_directive_string()
339        } else {
340            it.to_yaml_directive_string()
341        };
342        new_group_body.push_str(&block);
343    }
344
345    // Splice the group body in by locating its byte range.
346    let tree = locate::parse(source)?;
347    let path: Vec<&str> = vec!["report", group.name.as_str()];
348    let located = locate::locate_path(&tree, source, &path)?;
349    let range = match located {
350        locate::Located::Found { range } => range,
351        locate::Located::Missing { .. } => {
352            return Err(format!(
353                "located group '{}' via parser but tree-sitter could not find it",
354                group.name,
355            ));
356        }
357    };
358
359    // The group body is a block scalar. We need to format
360    // the replacement as a block-scalar value. The simplest
361    // form is a `|` literal block; the parser's existing
362    // group-body normaliser strips indentation, so we just
363    // need to indent each line by one more level than the
364    // group key.
365    let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
366    Ok(splice::replace_range(source, range, &block_scalar))
367}
368
369/// Wrap `body` as a YAML block scalar in `|` style,
370/// matching the indentation of the original value at
371/// `range` so the splice respects the surrounding shape.
372fn format_as_block_scalar(body: &str, source: &str, range: &std::ops::Range<usize>) -> String {
373    // Find the column of the first non-whitespace char at
374    // the start of the original value's first line — that's
375    // the indent the block-scalar continuation must match
376    // (or exceed) to be valid YAML.
377    let line_start = source[..range.start]
378        .rfind('\n')
379        .map(|i| i + 1)
380        .unwrap_or(0);
381    let pre_value = &source[line_start..range.start];
382    // The value started after the `key:` token + at least
383    // one space. The block-scalar continuation indent must
384    // be deeper than the key's column.
385    let key_column = pre_value.find(|c: char| !c.is_whitespace()).unwrap_or(0);
386    let cont_indent = key_column + 2;
387    let pad = " ".repeat(cont_indent);
388
389    let mut out = String::new();
390    out.push_str("|\n");
391    for line in body.split_inclusive('\n') {
392        let trimmed = line.trim_end_matches('\n');
393        if trimmed.is_empty() {
394            out.push('\n');
395            continue;
396        }
397        out.push_str(&pad);
398        out.push_str(trimmed);
399        out.push('\n');
400    }
401    // Drop the trailing newline so the splice doesn't add
402    // an extra blank line — the caller's range already
403    // ends one byte before the newline (per
404    // [`locate::value_byte_range`]).
405    if out.ends_with('\n') {
406        out.pop();
407    }
408    out
409}
410
411fn insert_new_item_at_anchor(
412    source: &str,
413    anchor: &Anchor,
414    group: &str,
415    item: &ReportItem,
416) -> Result<String, String> {
417    let tree = locate::parse(source)?;
418    let report_path: Vec<String> = anchor.report_path();
419    let report_path_refs: Vec<&str> = report_path.iter().map(String::as_str).collect();
420
421    // Try locating the report block first.
422    let located = locate::locate_path(&tree, source, &report_path_refs)?;
423    let group_path: Vec<&str> = {
424        let mut v = report_path_refs.clone();
425        v.push(group);
426        v
427    };
428    match located {
429        locate::Located::Found { range: _ } => {
430            // Report block exists. See whether the group
431            // also exists.
432            let group_located = locate::locate_path(&tree, source, &group_path)?;
433            match group_located {
434                locate::Located::Found { range } => {
435                    // Group exists: append the new item to
436                    // its body.
437                    let existing_body = &source[range.clone()];
438                    let block_for_existing_group = strip_block_scalar_indent(existing_body);
439                    let new_body = format!(
440                        "{}{}",
441                        block_for_existing_group,
442                        item.to_yaml_directive_string(),
443                    );
444                    let block_scalar = format_as_block_scalar(&new_body, source, &range);
445                    Ok(splice::replace_range(source, range, &block_scalar))
446                }
447                locate::Located::Missing {
448                    insert_at, indent, ..
449                } => {
450                    // Report exists, group doesn't — insert
451                    // a new group key under report.
452                    let pad = " ".repeat(indent);
453                    let block = format_new_group(group, item, indent);
454                    let inserted = format!("{pad}{block}");
455                    Ok(splice::insert_at(
456                        source,
457                        insert_at,
458                        &ensure_leading_newline(source, insert_at, &inserted),
459                    ))
460                }
461            }
462        }
463        locate::Located::Missing {
464            insert_at, indent, ..
465        } => {
466            // No `report:` (or intermediate) yet. For now
467            // only handle the root case; nested anchors land
468            // in Phase D where the dispatcher knows the
469            // intermediate scope keys exist.
470            match anchor {
471                Anchor::Root => {
472                    let block = format_new_report_block(group, item, indent);
473                    Ok(splice::insert_at(
474                        source,
475                        insert_at,
476                        &ensure_leading_newline(source, insert_at, &block),
477                    ))
478                }
479                _ => Err(format!(
480                    "anchor {} not yet supported by Phase B (Phase D will materialise \
481                     intermediate scope keys)",
482                    anchor.label(),
483                )),
484            }
485        }
486    }
487}
488
489/// Strip the common indent prefix from a block-scalar body
490/// so the directive lines are at column 0. Used when
491/// reading a YAML-stored group body before we re-format.
492fn strip_block_scalar_indent(body: &str) -> String {
493    // Find the minimum indent across non-empty lines.
494    let lines: Vec<&str> = body.split('\n').collect();
495    let min_indent = lines
496        .iter()
497        .filter(|l| !l.trim().is_empty())
498        .map(|l| l.len() - l.trim_start_matches(' ').len())
499        .min()
500        .unwrap_or(0);
501    let mut out = String::new();
502    for (i, line) in lines.iter().enumerate() {
503        if i > 0 {
504            out.push('\n');
505        }
506        if line.len() >= min_indent {
507            out.push_str(&line[min_indent..]);
508        } else {
509            out.push_str(line);
510        }
511    }
512    if !out.ends_with('\n') {
513        out.push('\n');
514    }
515    out
516}
517
518fn format_new_group(group: &str, item: &ReportItem, indent: usize) -> String {
519    // `<group>: |\n  <item directive lines>` indented to
520    // `indent + 2` for the continuation.
521    let pad = " ".repeat(indent + 2);
522    let mut out = String::new();
523    out.push_str(group);
524    out.push_str(": |\n");
525    for line in item.to_yaml_directive_string().split_inclusive('\n') {
526        let trimmed = line.trim_end_matches('\n');
527        if trimmed.is_empty() {
528            out.push('\n');
529            continue;
530        }
531        out.push_str(&pad);
532        out.push_str(trimmed);
533        out.push('\n');
534    }
535    out
536}
537
538fn format_new_report_block(group: &str, item: &ReportItem, indent: usize) -> String {
539    // `report:\n  <group>: |\n    <directives>`. Used when
540    // the root has no report block yet.
541    let outer = " ".repeat(indent);
542    let mut out = String::new();
543    out.push_str(&outer);
544    out.push_str("report:\n");
545    let inner = format_new_group(group, item, indent + 2);
546    out.push_str(&" ".repeat(indent + 2));
547    out.push_str(&inner);
548    out
549}
550
551fn ensure_leading_newline(source: &str, offset: usize, content: &str) -> String {
552    // If the byte at `offset-1` isn't a newline, the
553    // inserted content needs to start with one to keep its
554    // first line on a line of its own.
555    if offset == 0 || source.as_bytes()[offset - 1] == b'\n' {
556        content.to_string()
557    } else {
558        format!("\n{content}")
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Public wrappers used by the CLI dispatch (Phase D).
564// ---------------------------------------------------------------------------
565
566pub fn replace_item(workload_path: &Path, item: &ReportItem) -> io::Result<()> {
567    with_workload(workload_path, |ctx| {
568        replace_existing_item(ctx.source, &item.name, item)
569    })
570}
571
572/// Rename a report item from `old_name` to `new_name` in the
573/// workload at `workload_path`. SRD-64 §6.6: pure metadata
574/// edit — anchor stays at the existing site.
575///
576/// `replace` controls collision policy:
577/// - `false` — error if `new_name` is already in use anywhere
578///   in the workload.
579/// - `true` — destructive overwrite: drop the existing item
580///   at `new_name` and rename `old_name` over it. Mutually
581///   exclusive with the existing-name path: a destructive
582///   rename leaves the workload with one item under
583///   `new_name`, holding the spec from `old_name`.
584///
585/// The collision-target item being a different kind from
586/// `old_name` is allowed — `--replace` is the user's
587/// affirmation that they want the destructive swap regardless.
588pub fn rename_item(
589    workload_path: &Path,
590    old_name: &str,
591    new_name: &str,
592    replace: bool,
593) -> io::Result<()> {
594    with_workload(workload_path, |ctx| {
595        let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(ctx.source)
596            .map_err(|e| format!("yaml parse: {e}"))?;
597        let report_value = v
598            .get("report")
599            .ok_or_else(|| format!("no `report:` block; cannot rename '{old_name}'"))?;
600        let parsed =
601            crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
602        let existing = parsed
603            .report
604            .find(old_name)
605            .ok_or_else(|| format!("item '{old_name}' not found"))?;
606        if old_name == new_name {
607            return Err(format!(
608                "rename: <old> and <new> are both '{old_name}' — nothing to do"
609            ));
610        }
611        let target_collides = parsed.report.find(new_name).is_some();
612        if target_collides && !replace {
613            return Err(format!(
614                "rename target '{new_name}' is already in use; \
615                 pass --replace to drop the existing item under \
616                 '{new_name}' and rename '{old_name}' over it, or \
617                 pick another name"
618            ));
619        }
620
621        let mut renamed: ReportItem = (*existing).clone();
622        renamed.name = new_name.to_string();
623
624        if target_collides {
625            // Destructive path: remove the existing item at
626            // `new_name` first, then rename `old_name` to
627            // `new_name`. Two splices; the second (rename)
628            // re-parses the post-delete source so byte
629            // offsets stay aligned.
630            let after_delete = remove_existing_item(ctx.source, new_name)?;
631            replace_existing_item(&after_delete, old_name, &renamed)
632        } else {
633            replace_existing_item(ctx.source, old_name, &renamed)
634        }
635    })
636}
637
638/// Remove `name` from its containing group's body, returning
639/// the post-delete source. Helper for the destructive
640/// `rename --replace` path.
641fn remove_existing_item(source: &str, name: &str) -> Result<String, String> {
642    let v: serde_json::Value = serde_yaml::from_str::<serde_json::Value>(source)
643        .map_err(|e| format!("workload yaml parse: {e}"))?;
644    let report_value = v
645        .get("report")
646        .ok_or_else(|| format!("no `report:` block; cannot remove '{name}'"))?;
647    let parsed =
648        crate::report::parse_report(report_value).map_err(|e| format!("report parse: {e}"))?;
649
650    let group = parsed
651        .report
652        .groups
653        .iter()
654        .find(|g| g.items.iter().any(|i| i.name == name))
655        .ok_or_else(|| format!("item '{name}' not found in any report group"))?;
656
657    let mut new_group_body = String::new();
658    for it in &group.items {
659        if it.name == name {
660            continue;
661        }
662        new_group_body.push_str(&it.to_yaml_directive_string());
663    }
664
665    // If the group is now empty, leave a single empty body
666    // string — re-emitting the empty group keeps the YAML
667    // structure stable. (Phase B's parser warns on empty
668    // groups; that's a pre-existing diagnostic, not a
669    // semantic failure.)
670    if new_group_body.is_empty() {
671        new_group_body.push('\n');
672    }
673
674    let tree = locate::parse(source)?;
675    let path: Vec<&str> = vec!["report", group.name.as_str()];
676    let located = locate::locate_path(&tree, source, &path)?;
677    let range = match located {
678        locate::Located::Found { range } => range,
679        locate::Located::Missing { .. } => {
680            return Err(format!(
681                "located group '{}' via parser but tree-sitter could not find it",
682                group.name,
683            ));
684        }
685    };
686    let block_scalar = format_as_block_scalar(&new_group_body, source, &range);
687    Ok(splice::replace_range(source, range, &block_scalar))
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::report::Kind;
694
695    fn fresh_workload(label: &str, content: &str) -> std::path::PathBuf {
696        let p = std::env::temp_dir().join(format!("nmbrs-edit-{label}-{}", std::process::id(),));
697        let _ = std::fs::remove_dir_all(&p);
698        std::fs::create_dir_all(&p).unwrap();
699        let path = p.join("w.yaml");
700        std::fs::write(&path, content).unwrap();
701        path
702    }
703
704    #[test]
705    fn add_item_inserts_new_report_block_when_none_exists() {
706        let path = fresh_workload(
707            "add_root_no_report",
708            concat!(
709                "phases:\n",
710                "  setup:\n",
711                "    ops:\n",
712                "      step: noop\n",
713            ),
714        );
715        let item = ReportItem {
716            kind: Kind::Plot,
717            name: "demo".to_string(),
718            label: Some("Demo".to_string()),
719            body: "over cycle\nmetric=throughput".to_string(),
720            ..Default::default()
721        };
722        let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
723        assert_eq!(outcome, AddOutcome::Inserted);
724        let content = std::fs::read_to_string(&path).unwrap();
725        assert!(
726            content.contains("report:"),
727            "should have inserted report:\n{content}"
728        );
729        assert!(content.contains("cli_added"));
730        assert!(content.contains("plot demo"));
731        // Pre-existing content untouched.
732        assert!(content.contains("phases:"));
733        assert!(content.contains("step: noop"));
734    }
735
736    #[test]
737    fn add_item_appends_to_existing_group_in_existing_report() {
738        let path = fresh_workload(
739            "add_to_existing",
740            concat!(
741                "report:\n",
742                "  cli_added: |\n",
743                "    plot first\n",
744                "      over cycle\n",
745                "phases:\n",
746                "  setup:\n",
747                "    ops:\n",
748                "      step: noop\n",
749            ),
750        );
751        let item = ReportItem {
752            kind: Kind::Plot,
753            name: "second".to_string(),
754            body: "over cycle".to_string(),
755            ..Default::default()
756        };
757        let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, false).expect("add");
758        assert_eq!(outcome, AddOutcome::Inserted);
759        let content = std::fs::read_to_string(&path).unwrap();
760        assert!(content.contains("plot first"));
761        assert!(content.contains("plot second"));
762        assert!(content.contains("step: noop"));
763    }
764
765    #[test]
766    fn add_item_collision_errors_without_replace() {
767        let path = fresh_workload(
768            "add_collision",
769            concat!(
770                "report:\n",
771                "  cli_added: |\n",
772                "    plot demo\n",
773                "      over cycle\n",
774            ),
775        );
776        let item = ReportItem {
777            kind: Kind::Plot,
778            name: "demo".to_string(),
779            body: "over cycle".to_string(),
780            ..Default::default()
781        };
782        let err = add_item(&path, &Anchor::Root, "cli_added", &item, false).unwrap_err();
783        assert!(err.to_string().contains("already defined"), "got: {err}");
784        // Workload byte-identical (no rotate, no commit).
785        let content = std::fs::read_to_string(&path).unwrap();
786        assert!(content.contains("plot demo"));
787        // No backup produced because the lock+rotate path
788        // was never reached past the validation error.
789    }
790
791    #[test]
792    fn add_item_replace_overwrites_in_place() {
793        let path = fresh_workload(
794            "replace_inplace",
795            concat!(
796                "report:\n",
797                "  cli_added: |\n",
798                "    plot demo\n",
799                "      over cycle\n",
800                "      label \"v1\"\n",
801            ),
802        );
803        let item = ReportItem {
804            kind: Kind::Plot,
805            name: "demo".to_string(),
806            label: Some("v2".to_string()),
807            body: "over cycle".to_string(),
808            ..Default::default()
809        };
810        let outcome = add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
811        assert_eq!(outcome, AddOutcome::Replaced);
812        let content = std::fs::read_to_string(&path).unwrap();
813        assert!(content.contains("v2"));
814        assert!(
815            !content.contains("v1"),
816            "old label should be gone, got:\n{content}"
817        );
818
819        // Backup pair: .bak holds pre-replace content.
820        let bak = path.with_extension("yaml.bak");
821        // The path API for `with_extension` doesn't append
822        // ".bak" — it replaces. Reach for the backup-paths
823        // helper instead.
824        let paths = backup::BackupPaths::for_workload(&path);
825        let _ = bak;
826        assert!(paths.bak.exists());
827        let bak_content = std::fs::read_to_string(&paths.bak).unwrap();
828        assert!(
829            bak_content.contains("v1"),
830            ".bak should hold pre-edit content"
831        );
832    }
833
834    #[test]
835    fn rename_item_updates_name_and_writes_backup() {
836        let path = fresh_workload(
837            "rename",
838            concat!(
839                "report:\n",
840                "  cli_added: |\n",
841                "    plot demo\n",
842                "      over cycle\n",
843            ),
844        );
845        rename_item(&path, "demo", "demo_v2", false).expect("rename");
846        let content = std::fs::read_to_string(&path).unwrap();
847        assert!(content.contains("plot demo_v2"));
848        assert!(
849            !content.contains("plot demo\n"),
850            "original name should be gone:\n{content}"
851        );
852        let paths = backup::BackupPaths::for_workload(&path);
853        assert!(paths.bak.exists());
854    }
855
856    #[test]
857    fn rename_target_collision_errors_without_replace() {
858        let path = fresh_workload(
859            "rename_collision",
860            concat!(
861                "report:\n",
862                "  cli_added: |\n",
863                "    plot a\n",
864                "      over cycle\n",
865                "    plot b\n",
866                "      over cycle\n",
867            ),
868        );
869        let err = rename_item(&path, "a", "b", false).unwrap_err();
870        assert!(err.to_string().contains("already in use"), "got: {err}");
871        assert!(
872            err.to_string().contains("--replace"),
873            "should hint at the remediation flag: {err}"
874        );
875    }
876
877    #[test]
878    fn rename_target_collision_with_replace_drops_existing_target() {
879        let path = fresh_workload(
880            "rename_collision_replace",
881            concat!(
882                "report:\n",
883                "  cli_added: |\n",
884                "    plot a\n",
885                "      label \"keep this spec\"\n",
886                "      over cycle\n",
887                "    plot b\n",
888                "      label \"drop this spec\"\n",
889                "      over cycle\n",
890            ),
891        );
892        rename_item(&path, "a", "b", true).expect("destructive rename");
893        let content = std::fs::read_to_string(&path).unwrap();
894        // Exactly one item named `b` remains.
895        let b_count = content.matches("plot b").count();
896        assert_eq!(
897            b_count, 1,
898            "should have exactly one `plot b`, got:\n{content}"
899        );
900        // `a` is gone.
901        assert!(
902            !content.contains("plot a\n"),
903            "original `a` should be gone:\n{content}"
904        );
905        // The surviving spec is `a`'s, renamed.
906        assert!(
907            content.contains("keep this spec"),
908            "`a`'s spec should have survived under name `b`:\n{content}"
909        );
910        assert!(
911            !content.contains("drop this spec"),
912            "`b`'s old spec should be gone:\n{content}"
913        );
914    }
915
916    #[test]
917    fn rename_same_name_is_a_noop_error() {
918        let path = fresh_workload(
919            "rename_same",
920            concat!(
921                "report:\n",
922                "  cli_added: |\n",
923                "    plot demo\n",
924                "      over cycle\n",
925            ),
926        );
927        let err = rename_item(&path, "demo", "demo", false).unwrap_err();
928        assert!(err.to_string().contains("nothing to do"), "got: {err}");
929    }
930
931    #[test]
932    fn malformed_mutation_aborts_without_committing() {
933        let path = fresh_workload(
934            "malformed",
935            concat!(
936                "report:\n",
937                "  cli_added: |\n",
938                "    plot demo\n",
939                "      over cycle\n",
940            ),
941        );
942        let original = std::fs::read_to_string(&path).unwrap();
943
944        let err = with_workload(&path, |_ctx| {
945            // Return malformed YAML (unbalanced quote)
946            // forces the post-edit roundtrip parser to fail.
947            Ok("\"unbalanced".to_string())
948        })
949        .unwrap_err();
950        assert!(err.to_string().contains("failed to parse"), "got: {err}");
951
952        let post = std::fs::read_to_string(&path).unwrap();
953        assert_eq!(
954            post, original,
955            "workload must be byte-identical after a failed mutation"
956        );
957    }
958
959    #[test]
960    fn anchor_report_path_shapes() {
961        assert_eq!(Anchor::Root.report_path(), vec!["report"]);
962        assert_eq!(
963            Anchor::Scenario("foo".into()).report_path(),
964            vec!["scenarios", "foo", "report"]
965        );
966        assert_eq!(
967            Anchor::Phase("setup".into()).report_path(),
968            vec!["phases", "setup", "report"]
969        );
970        assert_eq!(
971            Anchor::Op {
972                phase: "p".into(),
973                op: "o".into()
974            }
975            .report_path(),
976            vec!["phases", "p", "ops", "o", "report"]
977        );
978    }
979
980    #[allow(dead_code)]
981    fn require_kind() {
982        // Compile-time poke so unused `Kind` import is
983        // stable when tests get pruned.
984        let _ = Kind::Plot;
985    }
986
987    #[test]
988    fn comments_outside_edit_range_survive_byte_identical() {
989        // SRD-64 §6.4: AST-preserving edit. Comments in
990        // unrelated parts of the file must come through
991        // exactly as written.
992        let source = concat!(
993            "# Top-level workload comment\n",
994            "# Multi-line\n",
995            "params:\n",
996            "  cycles: \"100\"  # inline comment on cycles\n",
997            "\n",
998            "# Comment between blocks\n",
999            "report:\n",
1000            "  cli_added: |\n",
1001            "    plot demo\n",
1002            "      over cycle\n",
1003            "\n",
1004            "phases:\n",
1005            "  # phase-block comment\n",
1006            "  setup:\n",
1007            "    ops:\n",
1008            "      step: noop\n",
1009        );
1010        let path = fresh_workload("comments_survive", source);
1011
1012        let item = ReportItem {
1013            kind: Kind::Plot,
1014            name: "demo".to_string(),
1015            label: Some("Updated".to_string()),
1016            body: "over cycle".to_string(),
1017            ..Default::default()
1018        };
1019        add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
1020
1021        let post = std::fs::read_to_string(&path).unwrap();
1022        // Every comment must still be present, byte-equal.
1023        for c in [
1024            "# Top-level workload comment",
1025            "# Multi-line",
1026            "# inline comment on cycles",
1027            "# Comment between blocks",
1028            "# phase-block comment",
1029        ] {
1030            assert!(post.contains(c), "missing comment {c:?} in:\n{post}");
1031        }
1032        // The non-edit phase block must be byte-identical
1033        // up to and including the trailing newline.
1034        let unrelated = "phases:\n  # phase-block comment\n  setup:\n    ops:\n      step: noop\n";
1035        assert!(
1036            post.contains(unrelated),
1037            "phases block changed; got:\n{post}"
1038        );
1039    }
1040
1041    #[test]
1042    fn quote_styles_outside_edit_range_preserved() {
1043        let source = concat!(
1044            "params:\n",
1045            "  a: \"double\"\n",
1046            "  b: 'single'\n",
1047            "  c: bare\n",
1048            "report:\n",
1049            "  cli_added: |\n",
1050            "    plot demo\n",
1051            "      over cycle\n",
1052        );
1053        let path = fresh_workload("quotes_preserved", source);
1054        let item = ReportItem {
1055            kind: Kind::Plot,
1056            name: "demo".to_string(),
1057            label: Some("X".to_string()),
1058            body: "over cycle".to_string(),
1059            ..Default::default()
1060        };
1061        add_item(&path, &Anchor::Root, "cli_added", &item, true).expect("replace");
1062        let post = std::fs::read_to_string(&path).unwrap();
1063        assert!(post.contains("a: \"double\""), "double quotes lost: {post}");
1064        assert!(post.contains("b: 'single'"), "single quotes lost: {post}");
1065        assert!(post.contains("c: bare"), "bare scalar lost: {post}");
1066    }
1067}