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 name: None,
227 title: None,
228 subject: None,
229 version: None,
230 description: None,
231 authors: None,
232 schema: Some("default@1.0.0".parse().unwrap()),
233 write_guidance: Default::default(),
234 process_mem: None,
235 rules: None,
236 publish: None,
237 language: None,
238 read_mems: Default::default(),
239 community: None,
240 vcs: None,
241 unregistered_at: None,
242 sync_state: Default::default(),
243 review_mark: None,
244 mutation_stamp: None,
245 extra: Default::default(),
246 };
247 let meta = dir.join(memstead_schema::MEM_META_DIR);
248 std::fs::create_dir_all(&meta).unwrap();
249 std::fs::write(
250 meta.join("config.json"),
251 serde_json::to_vec_pretty(&config).unwrap(),
252 )
253 .unwrap();
254 }
255 let mount = crate::Mount {
256 mem: "specs".to_string(),
257 schema: Some(memstead_schema::SchemaRef::new(
258 "default",
259 semver::Version::new(1, 0, 0),
260 )),
261 storage: crate::MountStorage::Folder { path: dir.clone() },
262 capability: crate::MountCapability::Write,
263 lifecycle: crate::MountLifecycle::Eager,
264 cross_linkable: false,
265 migration_target: None,
266 };
267 let backend =
268 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
269 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
270 }
271
272 fn head(engine: &crate::Engine) -> String {
273 engine
274 .review_marks()
275 .into_iter()
276 .find(|s| s.mem == "specs")
277 .and_then(|s| s.head)
278 .expect("folder head cursor")
279 }
280
281 #[test]
282 fn mark_lifecycle_persists_validates_and_never_gates() {
283 let tmp = tempfile::TempDir::new().unwrap();
284 let mut engine = folder_engine(&tmp);
285
286 let status = &engine.review_marks()[0];
288 assert_eq!(status.mem, "specs");
289 assert!(status.mark.is_none());
290 assert!(status.writable);
291
292 let err = engine
294 .set_review_mark("specs", Some("not-a-timestamp"), None)
295 .unwrap_err();
296 assert_eq!(err.code(), "INVALID_CURSOR");
297 assert!(engine.review_marks()[0].mark.is_none());
298 let err = engine.set_review_mark("ghost", None, None).unwrap_err();
300 assert_eq!(err.code(), "UNKNOWN_MEM");
301
302 let err = engine.review_mark_diff("specs", None).unwrap_err();
304 assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
305
306 let reviewed = head(&engine);
308 let outcome = engine
309 .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
310 .unwrap();
311 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
312 assert!(outcome.previous.is_none());
313 let diff = engine.review_mark_diff("specs", None).unwrap();
314 assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
315
316 let outcome = engine
320 .create_entity(
321 crate::CreateEntityArgs {
322 mem: "specs".to_string(),
323 title: "Past The Mark".to_string(),
324 entity_type: "spec".to_string(),
325 sections: [
326 ("identity".to_string(), "x".to_string()),
327 ("purpose".to_string(), "y".to_string()),
328 ]
329 .into_iter()
330 .collect(),
331 metadata: Default::default(),
332 relations: Vec::new(),
333 anchors: Vec::new(),
334 dry_run: false,
335 },
336 crate::vcs::Actor::App,
337 None,
338 Some("agent work"),
339 )
340 .unwrap();
341 assert!(
342 outcome
343 .warnings
344 .iter()
345 .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
346 "marks never gate or warn: {:?}",
347 outcome.warnings
348 );
349 let diff = engine.review_mark_diff("specs", None).unwrap();
350 assert_eq!(diff.changes.len(), 1, "{diff:?}");
351 let direct = engine.changes_since("specs", &reviewed, None).unwrap();
352 assert_eq!(
353 serde_json::to_value(&diff.changes).unwrap(),
354 serde_json::to_value(&direct.changes).unwrap(),
355 "diff-since must equal changes_since at the mark"
356 );
357
358 drop(engine);
361 let mut second = folder_engine(&tmp);
362 assert_eq!(
363 second.review_marks()[0].mark.as_deref(),
364 Some(reviewed.as_str()),
365 "the mark is mem-repo state"
366 );
367
368 let outcome = second.set_review_mark("specs", None, None).unwrap();
370 assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
371 assert!(second.review_marks()[0].mark.is_none());
372 }
373
374 #[test]
375 fn noteless_set_under_require_notes_warns_and_commits() {
376 let tmp = tempfile::TempDir::new().unwrap();
377 let mut engine = folder_engine(&tmp);
378 engine.set_settings(crate::WorkspaceSettings {
379 mutations: crate::workspace::MutationsSection {
380 require_notes: Some(true),
381 },
382 ..Default::default()
383 });
384 let reviewed = head(&engine);
385 let outcome = engine
386 .set_review_mark("specs", Some(&reviewed), None)
387 .unwrap();
388 assert!(
389 outcome.warnings.iter().any(
390 |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
391 ),
392 "warn-and-commit: {:?}",
393 outcome.warnings
394 );
395 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
396 }
397
398 #[test]
399 fn published_projection_strips_the_mark() {
400 let mut config = memstead_schema::config::MemConfig {
405 name: None,
406 title: None,
407 subject: None,
408 version: Some(semver::Version::new(1, 0, 0)),
409 description: None,
410 authors: None,
411 schema: Some("default@1.0.0".parse().unwrap()),
412 write_guidance: Default::default(),
413 process_mem: None,
414 rules: None,
415 publish: None,
416 language: None,
417 read_mems: Default::default(),
418 community: None,
419 vcs: None,
420 unregistered_at: None,
421 sync_state: Default::default(),
422 review_mark: None,
423 mutation_stamp: None,
424 extra: Default::default(),
425 };
426 config.review_mark = Some("deadbeef".to_string());
427 let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
428 let json = serde_json::to_string(&published).unwrap();
429 assert!(
430 !json.contains("reviewMark") && !json.contains("deadbeef"),
431 "published config must strip the mark: {json}"
432 );
433 }
434
435 #[test]
436 fn overview_roster_carries_the_mark_and_its_indicator() {
437 let tmp = tempfile::TempDir::new().unwrap();
442 let mut engine = folder_engine(&tmp);
443 let overview_md = |engine: &mut crate::Engine| {
444 crate::overview::compose_overview(
445 engine,
446 crate::overview::OverviewArgs {
447 include: &[],
448 mem: None,
449 rebuild: false,
450 token_budget: 8000,
451 operator_mode: false,
452 suppress_lifecycle: false,
453 },
454 crate::overview::Surface::Mcp,
455 )
456 .unwrap()
457 .markdown
458 };
459
460 let md = overview_md(&mut engine);
462 assert!(
463 !md.contains("Review mark"),
464 "markless roster must not mention marks: {md}"
465 );
466
467 let reviewed = head(&engine);
469 engine
470 .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
471 .unwrap();
472 let md = overview_md(&mut engine);
473 assert!(
474 md.contains(&format!("**Review mark:** `{reviewed}`")),
475 "roster must carry the mark value: {md}"
476 );
477 assert!(
478 md.contains("head is at the mark"),
479 "at-mark indicator missing: {md}"
480 );
481
482 engine
485 .create_entity(
486 crate::CreateEntityArgs {
487 mem: "specs".to_string(),
488 title: "Past The Mark Roster".to_string(),
489 entity_type: "spec".to_string(),
490 sections: [
491 ("identity".to_string(), "x".to_string()),
492 ("purpose".to_string(), "y".to_string()),
493 ]
494 .into_iter()
495 .collect(),
496 metadata: Default::default(),
497 relations: Vec::new(),
498 anchors: Vec::new(),
499 dry_run: false,
500 },
501 crate::vcs::Actor::App,
502 None,
503 Some("agent work"),
504 )
505 .unwrap();
506 let md = overview_md(&mut engine);
507 assert!(
508 md.contains("head has moved past the mark") && md.contains("changes_since"),
509 "unreviewed indicator missing: {md}"
510 );
511 }
512}