1use serde::Serialize;
19
20use super::{Engine, EngineError};
21
22#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25pub struct ReviewMarkStatus {
26 pub mem: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
30 pub mark: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub head: Option<String>,
34 pub writable: bool,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct SetReviewMarkOutcome {
42 pub mem: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub mark: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub previous: Option<String>,
49 pub warnings: Vec<crate::ops::WarningHint>,
52}
53
54impl Engine {
55 pub fn review_marks(&self) -> Vec<ReviewMarkStatus> {
58 self.mounts
59 .iter()
60 .map(|m| ReviewMarkStatus {
61 mem: m.mount.mem.clone(),
62 mark: m.mem_config.as_ref().and_then(|c| c.review_mark.clone()),
63 head: m.backend.current_head().ok().flatten(),
64 writable: m.mount.capability == crate::workspace::MountCapability::Write,
65 })
66 .collect()
67 }
68
69 pub fn set_review_mark(
79 &mut self,
80 mem_name: &str,
81 target: Option<&str>,
82 note: Option<&str>,
83 ) -> Result<SetReviewMarkOutcome, EngineError> {
84 let mount_idx = self
85 .mounts
86 .iter()
87 .position(|m| m.mount.mem == mem_name)
88 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
89 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
90 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
91 }
92
93 if let Some(target) = target {
96 self.validate_review_cursor(mount_idx, mem_name, target)?;
97 }
98
99 let mut warnings = self.reload_if_stale(Some(mem_name));
101 if let Some(w) = self.note_missing_warning("set_review_mark", note) {
102 warnings.push(w);
103 }
104
105 let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
109 let target_owned = target.map(str::to_string);
110 let (_, intervened) = self.write_mem_config_merged(
111 mount_idx,
112 mem_name,
113 note,
114 &|c: &mut memstead_schema::config::MemConfig| {
115 *seen.borrow_mut() = c.review_mark.clone();
116 c.review_mark = target_owned.clone();
117 },
118 )?;
119 let previous = seen.into_inner();
120 if !intervened.is_empty() {
121 warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
122 mem: mem_name.to_string(),
123 fields: intervened,
124 });
125 }
126
127 Ok(SetReviewMarkOutcome {
128 mem: mem_name.to_string(),
129 mark: target.map(str::to_string),
130 previous,
131 warnings,
132 })
133 }
134
135 pub fn review_mark_diff(
141 &self,
142 mem_name: &str,
143 rename_similarity: Option<f32>,
144 ) -> Result<crate::ops::ChangesReport, EngineError> {
145 let mount = self
146 .mounts
147 .iter()
148 .find(|m| m.mount.mem == mem_name)
149 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
150 let mark = mount
151 .mem_config
152 .as_ref()
153 .and_then(|c| c.review_mark.clone())
154 .ok_or_else(|| EngineError::ReviewMarkNotSet {
155 mem: mem_name.to_string(),
156 })?;
157 self.changes_since(mem_name, &mark, rename_similarity)
158 }
159
160 fn validate_review_cursor(
162 &self,
163 mount_idx: usize,
164 mem_name: &str,
165 target: &str,
166 ) -> Result<(), EngineError> {
167 use crate::workspace::MountStorage;
168 let invalid = || EngineError::InvalidChangesCursor {
169 mem: mem_name.to_string(),
170 since: target.to_string(),
171 };
172 match &self.mounts[mount_idx].mount.storage {
173 MountStorage::Folder { .. } => {
174 if crate::filesystem::changelog::parse_rfc3339_utc(target).is_none() {
178 return Err(invalid());
179 }
180 Ok(())
181 }
182 MountStorage::GitBranch { .. } => {
183 self.changes_since(mem_name, target, None).map(|_| ())
187 }
188 MountStorage::Archive { .. } | MountStorage::InMemory => {
189 Err(invalid())
192 }
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use crate::storage::MemWriter;
200
201 const SEED: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
202
203 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
204 let dir = tmp.path().join("specs");
205 if !dir.exists() {
206 std::fs::create_dir_all(&dir).unwrap();
207 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
208 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
209 .unwrap();
210 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
211 crate::backend::MemBackend::append_provenance(
212 &writer,
213 &crate::provenance::Provenance::new(
214 std::time::SystemTime::now(),
215 crate::provenance::ProvenanceKind::Create,
216 Some("specs--seed".into()),
217 crate::vcs::Actor::Cli,
218 None,
219 None,
220 ),
221 )
222 .unwrap();
223 let config = memstead_schema::config::MemConfig {
226 format: None,
227 name: None,
228 title: None,
229 subject: None,
230 version: None,
231 description: None,
232 authors: None,
233 schema: Some("default@1.0.0".parse().unwrap()),
234 write_guidance: Default::default(),
235 process_mem: None,
236 rules: None,
237 publish: None,
238 language: None,
239 read_mems: Default::default(),
240 community: None,
241 vcs: None,
242 unregistered_at: None,
243 sync_state: Default::default(),
244 review_mark: None,
245 mutation_stamp: None,
246 extra: Default::default(),
247 };
248 let meta = dir.join(memstead_schema::MEM_META_DIR);
249 std::fs::create_dir_all(&meta).unwrap();
250 std::fs::write(
251 meta.join("config.json"),
252 serde_json::to_vec_pretty(&config).unwrap(),
253 )
254 .unwrap();
255 }
256 let mount = crate::Mount {
257 mem: "specs".to_string(),
258 schema: Some(memstead_schema::SchemaRef::new(
259 "default",
260 semver::Version::new(1, 0, 0),
261 )),
262 storage: crate::MountStorage::Folder { path: dir.clone() },
263 capability: crate::MountCapability::Write,
264 lifecycle: crate::MountLifecycle::Eager,
265 cross_linkable: false,
266 migration_target: None,
267 };
268 let backend =
269 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
270 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
271 }
272
273 fn head(engine: &crate::Engine) -> String {
274 engine
275 .review_marks()
276 .into_iter()
277 .find(|s| s.mem == "specs")
278 .and_then(|s| s.head)
279 .expect("folder head cursor")
280 }
281
282 #[test]
283 fn mark_lifecycle_persists_validates_and_never_gates() {
284 let tmp = tempfile::TempDir::new().unwrap();
285 let mut engine = folder_engine(&tmp);
286
287 let status = &engine.review_marks()[0];
289 assert_eq!(status.mem, "specs");
290 assert!(status.mark.is_none());
291 assert!(status.writable);
292
293 let err = engine
295 .set_review_mark("specs", Some("not-a-timestamp"), None)
296 .unwrap_err();
297 assert_eq!(err.code(), "INVALID_CURSOR");
298 assert!(engine.review_marks()[0].mark.is_none());
299 let err = engine.set_review_mark("ghost", None, None).unwrap_err();
301 assert_eq!(err.code(), "UNKNOWN_MEM");
302
303 let err = engine.review_mark_diff("specs", None).unwrap_err();
305 assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
306
307 let reviewed = head(&engine);
309 let outcome = engine
310 .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
311 .unwrap();
312 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
313 assert!(outcome.previous.is_none());
314 let diff = engine.review_mark_diff("specs", None).unwrap();
315 assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
316
317 let outcome = engine
321 .create_entity(
322 crate::CreateEntityArgs {
323 mem: "specs".to_string(),
324 title: "Past The Mark".to_string(),
325 entity_type: "spec".to_string(),
326 sections: [
327 ("identity".to_string(), "x".to_string()),
328 ("purpose".to_string(), "y".to_string()),
329 ]
330 .into_iter()
331 .collect(),
332 metadata: Default::default(),
333 relations: Vec::new(),
334 anchors: Vec::new(),
335 dry_run: false,
336 },
337 crate::vcs::Actor::App,
338 None,
339 Some("agent work"),
340 )
341 .unwrap();
342 assert!(
343 outcome
344 .warnings
345 .iter()
346 .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
347 "marks never gate or warn: {:?}",
348 outcome.warnings
349 );
350 let diff = engine.review_mark_diff("specs", None).unwrap();
351 assert_eq!(diff.changes.len(), 1, "{diff:?}");
352 let direct = engine.changes_since("specs", &reviewed, None).unwrap();
353 assert_eq!(
354 serde_json::to_value(&diff.changes).unwrap(),
355 serde_json::to_value(&direct.changes).unwrap(),
356 "diff-since must equal changes_since at the mark"
357 );
358
359 drop(engine);
362 let mut second = folder_engine(&tmp);
363 assert_eq!(
364 second.review_marks()[0].mark.as_deref(),
365 Some(reviewed.as_str()),
366 "the mark is mem-repo state"
367 );
368
369 let outcome = second.set_review_mark("specs", None, None).unwrap();
371 assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
372 assert!(second.review_marks()[0].mark.is_none());
373 }
374
375 #[test]
376 fn noteless_set_under_require_notes_warns_and_commits() {
377 let tmp = tempfile::TempDir::new().unwrap();
378 let mut engine = folder_engine(&tmp);
379 engine.set_settings(crate::WorkspaceSettings {
380 mutations: crate::workspace::MutationsSection {
381 require_notes: Some(true),
382 },
383 ..Default::default()
384 });
385 let reviewed = head(&engine);
386 let outcome = engine
387 .set_review_mark("specs", Some(&reviewed), None)
388 .unwrap();
389 assert!(
390 outcome.warnings.iter().any(
391 |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
392 ),
393 "warn-and-commit: {:?}",
394 outcome.warnings
395 );
396 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
397 }
398
399 #[test]
400 fn published_projection_strips_the_mark() {
401 let mut config = memstead_schema::config::MemConfig {
406 format: None,
407 name: None,
408 title: None,
409 subject: None,
410 version: Some(semver::Version::new(1, 0, 0)),
411 description: None,
412 authors: None,
413 schema: Some("default@1.0.0".parse().unwrap()),
414 write_guidance: Default::default(),
415 process_mem: None,
416 rules: None,
417 publish: None,
418 language: None,
419 read_mems: Default::default(),
420 community: None,
421 vcs: None,
422 unregistered_at: None,
423 sync_state: Default::default(),
424 review_mark: None,
425 mutation_stamp: None,
426 extra: Default::default(),
427 };
428 config.review_mark = Some("deadbeef".to_string());
429 let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
430 let json = serde_json::to_string(&published).unwrap();
431 assert!(
432 !json.contains("reviewMark") && !json.contains("deadbeef"),
433 "published config must strip the mark: {json}"
434 );
435 }
436
437 #[test]
438 fn overview_roster_carries_the_mark_and_its_indicator() {
439 let tmp = tempfile::TempDir::new().unwrap();
444 let mut engine = folder_engine(&tmp);
445 let overview_md = |engine: &mut crate::Engine| {
446 crate::overview::compose_overview(
447 engine,
448 crate::overview::OverviewArgs {
449 include: &[],
450 mem: None,
451 rebuild: false,
452 token_budget: 8000,
453 operator_mode: false,
454 suppress_lifecycle: false,
455 },
456 crate::overview::Surface::Mcp,
457 )
458 .unwrap()
459 .markdown
460 };
461
462 let md = overview_md(&mut engine);
464 assert!(
465 !md.contains("Review mark"),
466 "markless roster must not mention marks: {md}"
467 );
468
469 let reviewed = head(&engine);
471 engine
472 .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
473 .unwrap();
474 let md = overview_md(&mut engine);
475 assert!(
476 md.contains(&format!("**Review mark:** `{reviewed}`")),
477 "roster must carry the mark value: {md}"
478 );
479 assert!(
480 md.contains("head is at the mark"),
481 "at-mark indicator missing: {md}"
482 );
483
484 engine
487 .create_entity(
488 crate::CreateEntityArgs {
489 mem: "specs".to_string(),
490 title: "Past The Mark Roster".to_string(),
491 entity_type: "spec".to_string(),
492 sections: [
493 ("identity".to_string(), "x".to_string()),
494 ("purpose".to_string(), "y".to_string()),
495 ]
496 .into_iter()
497 .collect(),
498 metadata: Default::default(),
499 relations: Vec::new(),
500 anchors: Vec::new(),
501 dry_run: false,
502 },
503 crate::vcs::Actor::App,
504 None,
505 Some("agent work"),
506 )
507 .unwrap();
508 let md = overview_md(&mut engine);
509 assert!(
510 md.contains("head has moved past the mark") && md.contains("changes_since"),
511 "unreviewed indicator missing: {md}"
512 );
513 }
514}