use std::path::Path;
use anyhow::Context;
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct AfterEdge {
pub(crate) to: String,
#[serde(default)]
pub(crate) rank: i32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct DepSeq {
pub(crate) needs: Vec<String>,
pub(crate) after: Vec<AfterEdge>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct RawRelationships {
#[serde(default)]
needs: Vec<String>,
#[serde(default)]
after: Vec<AfterEdge>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct RawDepSeqToml {
#[serde(default)]
relationships: RawRelationships,
}
pub(crate) enum RelEdit<'a> {
Needs(&'a [String]),
After { to: &'a str, rank: i32 },
}
pub(crate) fn read(toml_path: &Path) -> anyhow::Result<DepSeq> {
let text = std::fs::read_to_string(toml_path)
.with_context(|| format!("dep/seq entity not found at {}", toml_path.display()))?;
let raw: RawDepSeqToml = toml::from_str(&text)
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
Ok(DepSeq {
needs: raw.relationships.needs,
after: raw.relationships.after,
})
}
pub(crate) fn append(toml_path: &Path, edit: &RelEdit<'_>) -> anyhow::Result<()> {
let text = std::fs::read_to_string(toml_path)
.with_context(|| format!("dep/seq entity not found at {}", toml_path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
let axis = match edit {
RelEdit::Needs(_) => "needs",
RelEdit::After { .. } => "after",
};
let array = doc
.get_mut("relationships")
.and_then(toml_edit::Item::as_table_mut)
.and_then(|t| t.get_mut(axis))
.and_then(toml_edit::Item::as_array_mut)
.with_context(|| {
format!(
"malformed entity at {}: missing seeded `[relationships].{axis}` array — restore the seeded `[relationships]` arrays (e.g. via the backfill) before adding edges; the file is left untouched",
toml_path.display()
)
})?;
let mut changed = false;
match edit {
RelEdit::Needs(refs) => {
for r in *refs {
changed |= push_str_if_absent(array, r.as_str());
}
}
RelEdit::After { to, rank } => {
let present = array.iter().any(|v| {
v.as_inline_table().is_some_and(|t| {
t.get("to").and_then(toml_edit::Value::as_str) == Some(to)
&& t.get("rank").and_then(toml_edit::Value::as_integer)
== Some(i64::from(*rank))
})
});
if !present {
let mut edge = toml_edit::InlineTable::new();
edge.insert("to", (*to).into());
edge.insert("rank", i64::from(*rank).into());
array.push(edge);
changed = true;
}
}
}
if !changed {
return Ok(()); }
crate::fsutil::write_atomic(toml_path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", toml_path.display()))?;
Ok(())
}
pub(crate) fn remove_after(
doc: &mut toml_edit::DocumentMut,
to: &str,
rank_ceiling: Option<i32>,
) -> anyhow::Result<usize> {
let array = doc
.get_mut("relationships")
.and_then(toml_edit::Item::as_table_mut)
.and_then(|t| t.get_mut("after"))
.and_then(toml_edit::Item::as_array_mut)
.with_context(|| {
"malformed entity: missing seeded `[relationships].after` array — restore the seeded arrays before removing edges; the file is left untouched"
})?;
let indices: Vec<usize> = array
.iter()
.enumerate()
.filter_map(|(idx, v)| {
let t = v.as_inline_table()?;
let to_matches = t.get("to").and_then(toml_edit::Value::as_str) == Some(to);
if !to_matches {
return None;
}
if let Some(ceiling) = rank_ceiling {
let rank = t.get("rank").and_then(toml_edit::Value::as_integer)?;
if rank > i64::from(ceiling) {
return None;
}
}
Some(idx)
})
.collect();
let count = indices.len();
for idx in indices.into_iter().rev() {
array.remove(idx);
}
Ok(count)
}
pub(crate) fn remove(
toml_path: &Path,
to: &str,
rank_ceiling: Option<i32>,
) -> anyhow::Result<usize> {
let text = std::fs::read_to_string(toml_path)
.with_context(|| format!("dep/seq entity not found at {}", toml_path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
let count = remove_after(&mut doc, to, rank_ceiling)?;
if count > 0 {
crate::fsutil::write_atomic(toml_path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", toml_path.display()))?;
}
Ok(count)
}
fn push_str_if_absent(array: &mut toml_edit::Array, value: &str) -> bool {
if array.iter().any(|v| v.as_str() == Some(value)) {
return false;
}
array.push(value);
true
}
pub(crate) fn apply_status(
doc: &mut toml_edit::DocumentMut,
managed: &[(&str, &str)],
hint: &str,
) -> anyhow::Result<bool> {
let unchanged = managed
.iter()
.filter(|(k, _)| *k != "updated")
.all(|(k, v)| doc.get(k).and_then(toml_edit::Item::as_str) == Some(*v));
if unchanged {
return Ok(false);
}
let table = doc.as_table_mut();
if managed.iter().any(|(k, _)| !table.contains_key(k)) {
anyhow::bail!("{hint}");
}
for (k, v) in managed {
table.insert(k, toml_edit::value(*v));
}
Ok(true)
}
pub(crate) fn apply_string_append(
doc: &mut toml_edit::DocumentMut,
field: &str,
value: &str,
) -> anyhow::Result<bool> {
let array = doc
.get_mut("relationships")
.and_then(toml_edit::Item::as_table_mut)
.and_then(|t| t.get_mut(field))
.and_then(toml_edit::Item::as_array_mut)
.with_context(|| {
format!(
"malformed entity: missing seeded `[relationships].{field}` array — restore the seeded `[relationships]` arrays (e.g. via the backfill) before adding edges; the file is left untouched"
)
})?;
Ok(push_str_if_absent(array, value))
}
pub(crate) fn set_authored_status(
path: &Path,
managed: &[(&str, &str)],
hint: &str,
) -> anyhow::Result<bool> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("entity not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let changed = apply_status(&mut doc, managed, hint)?;
if changed {
crate::fsutil::write_atomic(path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))?;
}
Ok(changed)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "string-append IO wrapper staged for the slice relate consumer"
)
)]
pub(crate) fn append_string_array(path: &Path, field: &str, value: &str) -> anyhow::Result<bool> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("entity not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let changed = apply_string_append(&mut doc, field, value)?;
if changed {
crate::fsutil::write_atomic(path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded() -> String {
"id = 1\nslug = \"a\"\ntitle = \"A\"\n\n[relationships]\nneeds = []\nafter = []\n"
.to_string()
}
fn write_tmp(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entity.toml");
std::fs::write(&path, body).unwrap();
(dir, path)
}
#[test]
fn read_on_no_relationships_table_is_empty() {
let (_dir, path) = write_tmp("id = 1\nslug = \"a\"\ntitle = \"A\"\n");
let ds = read(&path).unwrap();
assert_eq!(ds, DepSeq::default(), "absent table → empty DepSeq");
}
#[test]
fn read_round_trips_needs_and_after() {
let body = "[relationships]\nneeds = [\"ISS-002\"]\n\
after = [{ to = \"ISS-003\", rank = 2 }, { to = \"ISS-004\" }]\n";
let (_dir, path) = write_tmp(body);
let ds = read(&path).unwrap();
assert_eq!(ds.needs, vec!["ISS-002"]);
assert_eq!(
ds.after,
vec![
AfterEdge {
to: "ISS-003".to_string(),
rank: 2,
},
AfterEdge {
to: "ISS-004".to_string(),
rank: 0,
},
]
);
}
#[test]
fn append_refuses_when_relationships_table_absent() {
let (_dir, path) = write_tmp("id = 1\nslug = \"a\"\ntitle = \"A\"\n");
let before = std::fs::read_to_string(&path).unwrap();
let err = append(&path, &RelEdit::Needs(&["ISS-002".to_string()]));
assert!(err.is_err(), "absent [relationships] table is refused");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"untouched on refuse"
);
}
#[test]
fn append_refuses_when_needs_array_absent() {
let (_dir, path) = write_tmp("id = 1\n[relationships]\nafter = []\n");
let before = std::fs::read_to_string(&path).unwrap();
let err = append(&path, &RelEdit::Needs(&["ISS-002".to_string()]));
assert!(err.is_err(), "absent `needs` array is refused");
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn append_refuses_when_after_array_absent() {
let (_dir, path) = write_tmp("id = 1\n[relationships]\nneeds = []\n");
let before = std::fs::read_to_string(&path).unwrap();
let err = append(
&path,
&RelEdit::After {
to: "ISS-002",
rank: 0,
},
);
assert!(err.is_err(), "absent `after` array is refused");
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn append_refuse_message_is_non_destructive() {
let (_dir, path) = write_tmp("id = 1\nslug = \"a\"\ntitle = \"A\"\n");
let err = append(&path, &RelEdit::Needs(&["ISS-002".to_string()]))
.expect_err("absent table refuses");
let msg = format!("{err:#}").to_lowercase();
assert!(
!msg.contains("regenerate") && !msg.contains("recreate") && !msg.contains(" new`"),
"refuse must never instruct regeneration/recreation: {msg}"
);
}
#[test]
fn append_is_idempotent() {
let (_dir, path) = write_tmp(&seeded());
append(&path, &RelEdit::Needs(&["ISS-002".to_string()])).unwrap();
let once = std::fs::read_to_string(&path).unwrap();
append(&path, &RelEdit::Needs(&["ISS-002".to_string()])).unwrap();
assert_eq!(once, std::fs::read_to_string(&path).unwrap(), "idempotent");
assert_eq!(
read(&path).unwrap().needs,
vec!["ISS-002"],
"not duplicated"
);
}
#[test]
fn append_round_trip_golden_keeps_relationships_first() {
let mut body = seeded();
body.push_str("\n# hand note — keep me\n[custom]\nkeep = \"yes\"\n");
let (_dir, path) = write_tmp(&body);
append(
&path,
&RelEdit::Needs(&["ISS-002".to_string(), "RSK-001".to_string()]),
)
.unwrap();
append(
&path,
&RelEdit::After {
to: "ISS-003",
rank: 5,
},
)
.unwrap();
let ds = read(&path).unwrap();
assert_eq!(ds.needs, vec!["ISS-002", "RSK-001"]);
assert_eq!(
ds.after,
vec![AfterEdge {
to: "ISS-003".to_string(),
rank: 5,
}]
);
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.contains("# hand note — keep me"),
"comment survives"
);
assert!(written.contains("[custom]"), "inert table survives");
let rel = written
.find("[relationships]")
.expect("relationships present");
let custom = written.find("[custom]").expect("custom present");
assert!(
rel < custom,
"[relationships] stays before later content:\n{written}"
);
}
fn seeded_status_entity() -> String {
"id = 1\nslug = \"a\"\ntitle = \"A\"\n\
status = \"pending\"\nresolution = \"\"\nupdated = \"2020-01-01\"\n\
# hand note — keep me\n\
[relationships]\nneeds = [\"ISS-002\"]\nafter = []\n\
[[relation]]\nkind = \"refines\"\nto = \"SL-001\"\n"
.to_string()
}
const HINT: &str = "malformed: missing seeded `status` — restore the seeded keys; untouched";
#[test]
fn set_authored_status_no_op_holds_content_and_mtime() {
let (_dir, path) = write_tmp(&seeded_status_entity());
let before = std::fs::read_to_string(&path).unwrap();
let before_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let changed = set_authored_status(
&path,
&[
("status", "pending"),
("resolution", ""),
("updated", "2020-01-01"),
],
HINT,
)
.unwrap();
assert!(!changed, "no-op returns false");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"content held"
);
assert_eq!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
before_mtime,
"mtime held"
);
}
#[test]
fn set_authored_status_f1_refuses_non_destructively_and_touches_nothing() {
let (_dir, path) =
write_tmp("id = 1\nstatus = \"pending\"\n\n[relationships]\nneeds = []\nafter = []\n");
let before = std::fs::read_to_string(&path).unwrap();
let err = set_authored_status(
&path,
&[("status", "active"), ("updated", "2099-01-01")],
"malformed: restore the seeded `status`/`updated` keys; the file is left untouched",
)
.expect_err("missing managed key refuses");
let msg = format!("{err:#}").to_lowercase();
assert!(
!msg.contains("regenerate") && !msg.contains(" new`") && !msg.contains("scaffold"),
"F-1 refuse must be non-destructive: {msg}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"untouched on refuse"
);
}
#[test]
fn set_authored_status_multi_key_round_trips_preserving_structure() {
let (_dir, path) = write_tmp(&seeded_status_entity());
let changed = set_authored_status(
&path,
&[
("status", "done"),
("resolution", "completed"),
("updated", "2099-12-31"),
],
HINT,
)
.unwrap();
assert!(changed, "a real move returns true");
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("status = \"done\""));
assert!(written.contains("resolution = \"completed\""));
assert!(written.contains("updated = \"2099-12-31\""));
assert!(
written.contains("# hand note — keep me"),
"comment survives"
);
assert!(written.contains("[relationships]"), "inline table survives");
assert!(
written.contains("needs = [\"ISS-002\"]"),
"relationships content survives"
);
assert!(written.contains("[[relation]]"), "array-of-tables survives");
}
#[test]
fn set_authored_status_single_key_round_trips_no_updated() {
let (_dir, path) = write_tmp(&seeded_status_entity());
let changed = set_authored_status(&path, &[("status", "active")], HINT).unwrap();
assert!(changed);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("status = \"active\""));
assert!(
written.contains("updated = \"2020-01-01\""),
"updated untouched"
);
assert!(written.contains("[relationships]"), "structure preserved");
}
#[test]
fn append_string_array_idempotent_and_f1_refuse() {
let (_dir, path) = write_tmp(&seeded_status_entity());
assert!(
!append_string_array(&path, "needs", "ISS-002").unwrap(),
"idempotent"
);
assert!(
append_string_array(&path, "needs", "RSK-001").unwrap(),
"appends new"
);
assert_eq!(read(&path).unwrap().needs, vec!["ISS-002", "RSK-001"]);
let (_dir2, bare) = write_tmp("id = 1\nstatus = \"a\"\n");
let before = std::fs::read_to_string(&bare).unwrap();
let err = append_string_array(&bare, "needs", "X").expect_err("absent array refuses");
let msg = format!("{err:#}").to_lowercase();
assert!(
!msg.contains("regenerate") && !msg.contains("recreate"),
"non-destructive: {msg}"
);
assert_eq!(std::fs::read_to_string(&bare).unwrap(), before, "untouched");
}
fn seeded_with_after() -> String {
"id = 1\nslug = \"a\"\ntitle = \"A\"\n\n[relationships]\nneeds = []\n\
after = [\n { to = \"X\", rank = 0 },\n { to = \"X\", rank = 2 },\n { to = \"X\", rank = 5 },\n]\n"
.to_string()
}
fn seeded_mixed_after() -> String {
"id = 1\nslug = \"a\"\ntitle = \"A\"\n\n[relationships]\nneeds = []\n\
after = [\n { to = \"X\", rank = 0 },\n { to = \"Y\", rank = 1 },\n { to = \"Z\", rank = 2 },\n]\n"
.to_string()
}
#[test]
fn remove_after_all_matching() {
let (_dir, path) = write_tmp(&seeded_with_after());
let text = std::fs::read_to_string(&path).unwrap();
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let count = remove_after(&mut doc, "X", None).unwrap();
assert_eq!(count, 3, "all 3 edges to X removed");
let array = doc
.get("relationships")
.and_then(|v| v.as_table())
.and_then(|t| t.get("after"))
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(array.len(), 0, "no edges remain");
}
#[test]
fn remove_after_rank_ceiling() {
let (_dir, path) = write_tmp(&seeded_with_after());
let text = std::fs::read_to_string(&path).unwrap();
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let count = remove_after(&mut doc, "X", Some(2)).unwrap();
assert_eq!(count, 2, "only rank 0 and 2 removed");
let array = doc
.get("relationships")
.and_then(|v| v.as_table())
.and_then(|t| t.get("after"))
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(array.len(), 1, "one edge remains");
let remaining = array.get(0).and_then(|v| v.as_inline_table()).unwrap();
assert_eq!(
remaining.get("rank").and_then(|v| v.as_integer()),
Some(5),
"rank 5 edge kept"
);
}
#[test]
fn remove_after_no_match() {
let body = "id = 1\nslug = \"a\"\ntitle = \"A\"\n\n[relationships]\nneeds = []\n\
after = [{ to = \"Y\", rank = 0 }]\n";
let (_dir, path) = write_tmp(body);
let text = std::fs::read_to_string(&path).unwrap();
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let count = remove_after(&mut doc, "X", None).unwrap();
assert_eq!(count, 0, "no match → zero removed");
}
#[test]
fn remove_after_mixed_targets() {
let (_dir, path) = write_tmp(&seeded_mixed_after());
let text = std::fs::read_to_string(&path).unwrap();
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let count = remove_after(&mut doc, "X", None).unwrap();
assert_eq!(count, 1, "only X removed");
let array = doc
.get("relationships")
.and_then(|v| v.as_table())
.and_then(|t| t.get("after"))
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(array.len(), 2, "Y and Z remain");
let tos: Vec<&str> = array
.iter()
.filter_map(|v| {
v.as_inline_table()
.and_then(|t| t.get("to"))
.and_then(|v| v.as_str())
})
.collect();
assert_eq!(tos, vec!["Y", "Z"], "only Y and Z remain");
}
#[test]
fn remove_after_f1_refuse() {
let (_dir, path) = write_tmp("id = 1\nslug = \"a\"\ntitle = \"A\"\n");
let text = std::fs::read_to_string(&path).unwrap();
let mut doc = text.parse::<toml_edit::DocumentMut>().unwrap();
let err = remove_after(&mut doc, "X", None).expect_err("absent after array refuses");
let msg = format!("{err:#}").to_lowercase();
assert!(
msg.contains("malformed") || msg.contains("missing seeded"),
"F-1 refuse message: {msg}"
);
assert!(
!msg.contains("regenerate") && !msg.contains("recreate"),
"non-destructive: {msg}"
);
}
#[test]
fn remove_io_noop_holds_mtime() {
let body = "id = 1\nslug = \"a\"\ntitle = \"A\"\n\n[relationships]\nneeds = []\n\
after = [{ to = \"Y\", rank = 0 }]\n";
let (_dir, path) = write_tmp(body);
let before_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let count = remove(&path, "X", None).unwrap();
assert_eq!(count, 0, "no match");
assert_eq!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
before_mtime,
"mtime held on noop"
);
}
#[test]
fn remove_io_roundtrip() {
let body = r#"id = 1
slug = "a"
title = "A"
# keep this comment
[notes]
info = "survive"
[relationships]
needs = []
after = [
{ to = "X", rank = 0 },
{ to = "Y", rank = 1 },
]
"#;
let (_dir, path) = write_tmp(body);
let count = remove(&path, "X", None).unwrap();
assert_eq!(count, 1);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("# keep this comment"), "comment survives");
assert!(written.contains("[notes]"), "inert table survives");
assert!(
written.contains("info = \"survive\""),
"inert table content survives"
);
assert!(
written.contains("{ to = \"Y\", rank = 1 }"),
"Y edge survives"
);
assert!(!written.contains("{ to = \"X\""), "X edge removed");
}
}