authorclover caruso <git@paperclover.net> 2026-10-11 15:45:26-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 16:44:36-07:00
log55c3dea4989459688bb3a9146ea24566e797b3a2
tree1067aaffc2f19a60c6402493596ede5e3a2c179a
parent203a89351e23dd8e2a4cb1ce39977db479cb0b03
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(notebook): move discovered entries missing a source TOC reference

Read actual source TOC membership before renaming. Discovered sections and groups can be absent from the parent TOC; moving them still registers the destination without requesting an impossible strict source removal. Existing source references use the same append-only removal, and unreadable source TOCs fail before the filesystem move. Fixes #131 Refs: #125, #126 Assisted-by: gpt-6.1-sol

2 files changed, 123 insertions(+), 1 deletions(-)

crates/notebook/src/session.rs+10-1
......@@ -1384,6 +1384,15 @@ impl Notebook {
13841384 {
13851385 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
13861386 }
1387 let registered = if let (Some(identity), Some(toc)) = (identity, &self.folder(from)?.toc) {
1388 lists(
1389 &self.storage.read(&catalog_path(from, &toc.filename))?,
1390 identity,
1391 )?
1392 .then_some(identity)
1393 } else {
1394 None
1395 };
13871396 let (_, ancestor) = self.toc(folder)?;
13881397 self.storage.rename(path, &target)?;
13891398 if !copy {
......@@ -1404,7 +1413,7 @@ impl Notebook {
14041413 )?;
14051414 }
14061415 }
1407 if let Some(identity) = identity {
1416 if let Some(identity) = registered {
14081417 self.edit_toc(from, &[onestore::TocEdit::Remove { identity }])?;
14091418 }
14101419 self.refresh()?;
crates/notebook/src/session/tests.rs+113
......@@ -73,6 +73,119 @@ fn renaming_an_unlisted_section_creates_its_toc_entry_and_preserves_its_neighbor
7373 }
7474}
7575
76#[test]
77fn moving_discovered_entries_preserves_unregistered_source_tocs() {
78 for group in [false, true] {
79 for source_toc in ["missing", "empty", "unlisted", "registered"] {
80 let temporary = tempfile::tempdir().unwrap();
81 let folder = temporary.path().join("notebook");
82 let cache = temporary.path().join("cache");
83 let page = PageCreation::new(None, Some("Garden plan"), "Fixture").unwrap();
84 let mut notebook =
85 Notebook::create(&folder, &cache, Notebook::NEW_COLOR, &page).unwrap();
86 let first = notebook.catalog().sections[0].path.clone();
87 let neighbor = notebook.create_section("", "Neighbor", &page).unwrap();
88 let target = notebook.create_group("", "Target").unwrap();
89 let existing = notebook.create_section(&target, "Existing", &page).unwrap();
90 let (entry, section, identity) = if group {
91 let entry = notebook.create_group("", "Source").unwrap();
92 let section = notebook.create_section(&entry, "Inside", &page).unwrap();
93 let identity = notebook
94 .folder(&entry)
95 .unwrap()
96 .toc
97 .as_ref()
98 .unwrap()
99 .file_id;
100 (entry, section, identity)
101 } else {
102 let identity = notebook.section_path(&first).unwrap().file_id;
103 (first.clone(), first, identity)
104 };
105 let section_id = notebook.section_path(&section).unwrap().file_id;
106 let neighbor_bytes = fs::read(folder.join(&neighbor)).unwrap();
107 let existing_bytes = fs::read(folder.join(&existing)).unwrap();
108 if source_toc == "unlisted" {
109 notebook
110 .edit_toc("", &[onestore::TocEdit::Remove { identity }])
111 .unwrap();
112 }
113 drop(notebook);
114 if source_toc == "missing" {
115 fs::remove_file(folder.join(TOC)).unwrap();
116 } else if source_toc == "empty" {
117 fs::write(
118 folder.join(TOC),
119 onestore::create_table_of_contents(TOC, &[]).unwrap(),
120 )
121 .unwrap();
122 }
123 let before = fs::read(folder.join(TOC)).ok();
124 let mut notebook = Notebook::open(&folder, &cache).unwrap();
125 let order: Vec<_> = notebook
126 .catalog()
127 .order
128 .iter()
129 .filter(|path| **path != entry)
130 .cloned()
131 .collect();
132 let moved = catalog_path(&target, &entry);
133 assert_eq!(notebook.move_entry(&entry, &target).unwrap(), moved);
134 assert!(!folder.join(&entry).exists());
135 let moved_section = catalog_path(&target, &section);
136 let check = |notebook: &Notebook| {
137 assert_eq!(notebook.catalog().order, order);
138 assert_eq!(
139 notebook.section_path(&moved_section).unwrap().file_id,
140 section_id
141 );
142 assert!(lists(&notebook.read_toc(&target).unwrap(), identity).unwrap());
143 assert_eq!(
144 stored_pages(&notebook.read_section(&moved_section).unwrap()).unwrap()[0]
145 .page
146 .title,
147 "Garden plan"
148 );
149 if source_toc == "registered" {
150 let toc = notebook.read_toc("").unwrap();
151 assert!(!lists(&toc, identity).unwrap());
152 assert_eq!(
153 notebook.catalog().toc.as_ref().unwrap().color,
154 Some(Notebook::NEW_COLOR)
155 );
156 } else {
157 assert_eq!(fs::read(folder.join(TOC)).ok(), before);
158 }
159 assert_eq!(fs::read(folder.join(&neighbor)).unwrap(), neighbor_bytes);
160 assert_eq!(fs::read(folder.join(&existing)).unwrap(), existing_bytes);
161 };
162 check(&notebook);
163 drop(notebook);
164 check(&Notebook::open(&folder, temporary.path().join("cold-cache")).unwrap());
165 }
166 }
167}
168
169#[test]
170fn moving_an_entry_reads_its_registered_source_toc_before_renaming() {
171 let temporary = tempfile::tempdir().unwrap();
172 let folder = temporary.path().join("notebook");
173 let page = PageCreation::new(None, Some("Garden plan"), "Fixture").unwrap();
174 let mut notebook = Notebook::create(
175 &folder,
176 temporary.path().join("cache"),
177 Notebook::NEW_COLOR,
178 &page,
179 )
180 .unwrap();
181 let section = notebook.catalog().sections[0].path.clone();
182 notebook.create_group("", "Target").unwrap();
183 fs::write(folder.join(TOC), b"unreadable source TOC").unwrap();
184 assert!(notebook.move_entry(&section, "Target").is_err());
185 assert!(folder.join(&section).exists());
186 assert!(!folder.join("Target").join(&section).exists());
187}
188
76189struct FaultStorage {
77190 inner: Directory,
78191 revision: Mutex<Option<(String, Transaction)>>,