1use crate::engine::{Engine, EngineError, MemChangedEvent};
42use crate::entity::id::file_path_to_id;
43use crate::entity::loader::parse_entries;
44use crate::entity::source::SourceEntry;
45use crate::entity::store_builder::push_entities_into_store;
46use crate::ops::{CommitEnvelope, EntityChange};
47
48impl Engine {
49 pub fn apply_external_commit(&mut self, envelope: &CommitEnvelope) -> Result<(), EngineError> {
68 let mem = envelope.mem.as_str();
69
70 let mount_idx = self
73 .mounts
74 .iter()
75 .position(|m| m.mount.mem == mem)
76 .ok_or_else(|| self.unknown_mem_error(mem))?;
77 let schema = self
78 .schemas
79 .get(mem)
80 .cloned()
81 .ok_or_else(|| self.unknown_mem_error(mem))?;
82
83 let mut to_upsert: Vec<SourceEntry> = Vec::new();
88 let mut to_remove: Vec<String> = Vec::new();
89 for change in &envelope.changes {
90 match change {
91 EntityChange::Added { path, content }
92 | EntityChange::Modified { path, content } => {
93 to_upsert.push(SourceEntry {
94 relative_path: path.clone(),
95 source_path: std::path::PathBuf::from(path.clone()),
96 content: content.clone(),
97 });
98 }
99 EntityChange::Deleted { path } => {
100 to_remove.push(path.clone());
101 }
102 EntityChange::Renamed { from, to, content } => {
103 to_remove.push(from.clone());
104 to_upsert.push(SourceEntry {
105 relative_path: to.clone(),
106 source_path: std::path::PathBuf::from(to.clone()),
107 content: content.clone(),
108 });
109 }
110 }
111 }
112
113 let load_result = parse_entries(to_upsert, Vec::new(), mem, schema.as_ref());
120 if let Some((path, msg)) = load_result.errors.into_iter().next() {
121 return Err(EngineError::ParseAfterWrite(format!(
122 "apply_external_commit: failed to parse '{}' in mem '{}': {}",
123 path.display(),
124 mem,
125 msg
126 )));
127 }
128
129 for path in &to_remove {
133 let id = file_path_to_id(path, mem);
134 self.store.remove(&id);
135 }
136 let mut parsed = load_result.entities;
141 for result in parsed.iter_mut() {
142 result.entity.file_path = result.entity.id.0.clone();
143 }
144 let fallback = crate::engine_fallback_type();
151 push_entities_into_store(&mut self.store, parsed, fallback.as_ref(), None);
152
153 let previous = self
156 .mounts
157 .get(mount_idx)
158 .and_then(|m| m.last_known_head.clone())
159 .unwrap_or_default();
160 if let Some(state) = self.mounts.get_mut(mount_idx) {
161 state.last_known_head = Some(envelope.sha.clone());
162 }
163 self.invalidate_communities();
164 #[cfg(not(target_arch = "wasm32"))]
165 self.invalidate_search_indexes();
166
167 if previous != envelope.sha {
171 let event = MemChangedEvent {
172 mem: mem.to_string(),
173 head: envelope.sha.clone(),
174 previous,
175 n_commits: 1,
176 };
177 self.emit_mem_changed(&event);
178 }
179 Ok(())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::backend::MemBackend;
187 use crate::engine::test_helpers::*;
188 use crate::entity::EntityId;
189 use crate::storage::FilesystemMemWriter;
190 use std::collections::BTreeMap;
191 use std::sync::{Arc, Mutex};
192 use tempfile::TempDir;
193
194 fn empty_folder_engine(tmp: &TempDir, mem: &str) -> Engine {
195 let mem_dir = tmp.path().to_path_buf();
196 let writer = FilesystemMemWriter::new(mem_dir.clone());
197 Engine::from_mounts(vec![(
198 folder_mount(mem, mem_dir),
199 Box::new(writer) as Box<dyn MemBackend>,
200 )])
201 .unwrap()
202 }
203
204 fn envelope(mem: &str, sha: &str, parent: &str, changes: Vec<EntityChange>) -> CommitEnvelope {
205 CommitEnvelope {
206 sha: sha.to_string(),
207 parent: parent.to_string(),
208 mem: mem.to_string(),
209 timestamp: "2026-05-19T10:00:00Z".to_string(),
210 trailers: BTreeMap::new(),
211 changes,
212 }
213 }
214
215 fn well_formed_body(title: &str) -> String {
216 format!(
217 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{title}\n"
218 )
219 }
220
221 #[test]
222 fn apply_external_commit_unknown_mem_refuses() {
223 let tmp = TempDir::new().unwrap();
224 let mut engine = empty_folder_engine(&tmp, "specs");
225 let env = envelope("missing", "deadbeef", "", vec![]);
226 let err = engine.apply_external_commit(&env).unwrap_err();
227 assert_eq!(err.code(), "UNKNOWN_MEM");
228 }
229
230 #[test]
231 fn apply_external_commit_added_lands_entity_in_store() {
232 let tmp = TempDir::new().unwrap();
233 let mut engine = empty_folder_engine(&tmp, "specs");
234 let env = envelope(
235 "specs",
236 "abc123",
237 "",
238 vec![EntityChange::Added {
239 path: "alpha.md".to_string(),
240 content: well_formed_body("Alpha"),
241 }],
242 );
243 engine.apply_external_commit(&env).unwrap();
244 let id = EntityId::new("specs", "alpha");
245 assert!(
246 engine.get_entity(&id).is_some(),
247 "expected 'specs--alpha' in store after Added apply"
248 );
249 }
250
251 #[test]
252 fn apply_external_commit_modified_replaces_existing_body() {
253 let tmp = TempDir::new().unwrap();
254 let mut engine = empty_folder_engine(&tmp, "specs");
255 engine
257 .apply_external_commit(&envelope(
258 "specs",
259 "sha1",
260 "",
261 vec![EntityChange::Added {
262 path: "alpha.md".to_string(),
263 content: well_formed_body("Alpha-v0"),
264 }],
265 ))
266 .unwrap();
267 engine
269 .apply_external_commit(&envelope(
270 "specs",
271 "sha2",
272 "sha1",
273 vec![EntityChange::Modified {
274 path: "alpha.md".to_string(),
275 content: well_formed_body("Alpha-v1"),
276 }],
277 ))
278 .unwrap();
279 let id = EntityId::new("specs", "alpha");
280 let entity = engine.get_entity(&id).expect("alpha must still exist");
281 assert_eq!(entity.title, "Alpha-v1");
282 }
283
284 #[test]
285 fn apply_external_commit_deleted_removes_entity_from_store() {
286 let tmp = TempDir::new().unwrap();
287 let mut engine = empty_folder_engine(&tmp, "specs");
288 engine
289 .apply_external_commit(&envelope(
290 "specs",
291 "sha1",
292 "",
293 vec![EntityChange::Added {
294 path: "alpha.md".to_string(),
295 content: well_formed_body("Alpha"),
296 }],
297 ))
298 .unwrap();
299 engine
300 .apply_external_commit(&envelope(
301 "specs",
302 "sha2",
303 "sha1",
304 vec![EntityChange::Deleted {
305 path: "alpha.md".to_string(),
306 }],
307 ))
308 .unwrap();
309 let id = EntityId::new("specs", "alpha");
310 assert!(
311 engine.get_entity(&id).is_none(),
312 "expected 'specs--alpha' removed after Deleted apply"
313 );
314 }
315
316 #[test]
317 fn apply_external_commit_renamed_moves_entity_to_new_id() {
318 let tmp = TempDir::new().unwrap();
319 let mut engine = empty_folder_engine(&tmp, "specs");
320 engine
321 .apply_external_commit(&envelope(
322 "specs",
323 "sha1",
324 "",
325 vec![EntityChange::Added {
326 path: "alpha.md".to_string(),
327 content: well_formed_body("Alpha"),
328 }],
329 ))
330 .unwrap();
331 engine
332 .apply_external_commit(&envelope(
333 "specs",
334 "sha2",
335 "sha1",
336 vec![EntityChange::Renamed {
337 from: "alpha.md".to_string(),
338 to: "alpha-renamed.md".to_string(),
339 content: well_formed_body("Alpha Renamed"),
340 }],
341 ))
342 .unwrap();
343 let old_id = EntityId::new("specs", "alpha");
344 let new_id = EntityId::new("specs", "alpha-renamed");
345 assert!(engine.get_entity(&old_id).is_none(), "old id must be gone");
346 assert!(
347 engine.get_entity(&new_id).is_some(),
348 "new id must be present"
349 );
350 }
351
352 #[test]
353 fn apply_external_commit_mixed_changes_apply_atomically() {
354 let tmp = TempDir::new().unwrap();
355 let mut engine = empty_folder_engine(&tmp, "specs");
356 engine
358 .apply_external_commit(&envelope(
359 "specs",
360 "sha1",
361 "",
362 vec![
363 EntityChange::Added {
364 path: "alpha.md".to_string(),
365 content: well_formed_body("Alpha"),
366 },
367 EntityChange::Added {
368 path: "beta.md".to_string(),
369 content: well_formed_body("Beta"),
370 },
371 ],
372 ))
373 .unwrap();
374 engine
376 .apply_external_commit(&envelope(
377 "specs",
378 "sha2",
379 "sha1",
380 vec![
381 EntityChange::Deleted {
382 path: "alpha.md".to_string(),
383 },
384 EntityChange::Modified {
385 path: "beta.md".to_string(),
386 content: well_formed_body("Beta-v2"),
387 },
388 EntityChange::Added {
389 path: "gamma.md".to_string(),
390 content: well_formed_body("Gamma"),
391 },
392 ],
393 ))
394 .unwrap();
395 let alpha = EntityId::new("specs", "alpha");
396 let beta = EntityId::new("specs", "beta");
397 let gamma = EntityId::new("specs", "gamma");
398 assert!(engine.get_entity(&alpha).is_none());
399 assert_eq!(engine.get_entity(&beta).unwrap().title, "Beta-v2");
400 assert!(engine.get_entity(&gamma).is_some());
401 }
402
403 #[test]
404 fn apply_external_commit_permissive_parser_accepts_minimal_body() {
405 let tmp = TempDir::new().unwrap();
414 let mut engine = empty_folder_engine(&tmp, "specs");
415 let bare_body = "# Bare\n\nNo frontmatter, no schema sections.\n";
416 engine
417 .apply_external_commit(&envelope(
418 "specs",
419 "sha1",
420 "",
421 vec![EntityChange::Added {
422 path: "bare.md".to_string(),
423 content: bare_body.to_string(),
424 }],
425 ))
426 .unwrap();
427 let id = EntityId::new("specs", "bare");
428 let entity = engine
429 .get_entity(&id)
430 .expect("permissive parser must produce an entity even without frontmatter");
431 assert_eq!(entity.title, "Bare");
432 }
433
434 #[test]
435 fn apply_external_commit_emits_mem_changed_event_to_subscribers() {
436 let tmp = TempDir::new().unwrap();
437 let mut engine = empty_folder_engine(&tmp, "specs");
438 let observed = Arc::new(Mutex::new(Vec::<MemChangedEvent>::new()));
439 let observed_clone = observed.clone();
440 let _handle = engine
441 .subscribe_mem_changes(
442 "specs",
443 Arc::new(move |event| {
444 observed_clone.lock().unwrap().push(event.clone());
445 }),
446 )
447 .unwrap();
448 engine
449 .apply_external_commit(&envelope(
450 "specs",
451 "sha-new",
452 "",
453 vec![EntityChange::Added {
454 path: "alpha.md".to_string(),
455 content: well_formed_body("Alpha"),
456 }],
457 ))
458 .unwrap();
459 let events = observed.lock().unwrap();
460 assert_eq!(events.len(), 1, "expected one MemChangedEvent");
461 assert_eq!(events[0].head, "sha-new");
462 assert_eq!(events[0].mem, "specs");
463 assert_eq!(events[0].n_commits, 1);
464 }
465
466 #[test]
467 fn apply_external_commit_empty_changes_still_advances_head() {
468 let tmp = TempDir::new().unwrap();
469 let mut engine = empty_folder_engine(&tmp, "specs");
470 let observed = Arc::new(Mutex::new(Vec::<MemChangedEvent>::new()));
471 let observed_clone = observed.clone();
472 let _handle = engine
473 .subscribe_mem_changes(
474 "specs",
475 Arc::new(move |event| {
476 observed_clone.lock().unwrap().push(event.clone());
477 }),
478 )
479 .unwrap();
480 engine
481 .apply_external_commit(&envelope("specs", "empty-commit-sha", "", vec![]))
482 .unwrap();
483 let events = observed.lock().unwrap();
484 assert_eq!(events.len(), 1, "empty envelope must still emit one event");
485 assert_eq!(events[0].head, "empty-commit-sha");
486 }
487}