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 mounted = &mut self.mounts[mount_idx];
106 let mut config = mounted.mem_config.clone().ok_or_else(|| {
107 EngineError::InvalidInput(format!(
108 "mem '{mem_name}' has no loaded MemConfig — cannot set a review mark \
109 (initialize the mem via `memstead init` or `memstead mem create` first)"
110 ))
111 })?;
112
113 let previous = config.review_mark.clone();
114 config.review_mark = target.map(str::to_string);
115
116 let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
117 EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
118 })?;
119 bytes.push(b'\n');
120 mounted.backend.write_mem_config_with_note(&bytes, note)?;
121 mounted.mem_config = Some(config);
122
123 let new_head = mounted.backend.current_head().ok().flatten();
126 if let Some(sha) = new_head {
127 mounted.last_known_head = Some(sha);
128 }
129
130 Ok(SetReviewMarkOutcome {
131 mem: mem_name.to_string(),
132 mark: target.map(str::to_string),
133 previous,
134 warnings,
135 })
136 }
137
138 pub fn review_mark_diff(
144 &self,
145 mem_name: &str,
146 rename_similarity: Option<f32>,
147 ) -> Result<crate::ops::ChangesReport, EngineError> {
148 let mount = self
149 .mounts
150 .iter()
151 .find(|m| m.mount.mem == mem_name)
152 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
153 let mark = mount
154 .mem_config
155 .as_ref()
156 .and_then(|c| c.review_mark.clone())
157 .ok_or_else(|| EngineError::ReviewMarkNotSet {
158 mem: mem_name.to_string(),
159 })?;
160 self.changes_since(mem_name, &mark, rename_similarity)
161 }
162
163 fn validate_review_cursor(
165 &self,
166 mount_idx: usize,
167 mem_name: &str,
168 target: &str,
169 ) -> Result<(), EngineError> {
170 use crate::workspace::MountStorage;
171 let invalid = || EngineError::InvalidChangesCursor {
172 mem: mem_name.to_string(),
173 since: target.to_string(),
174 };
175 match &self.mounts[mount_idx].mount.storage {
176 MountStorage::Folder { .. } => {
177 if crate::filesystem::changelog::parse_rfc3339_utc(target).is_none() {
181 return Err(invalid());
182 }
183 Ok(())
184 }
185 MountStorage::GitBranch { .. } => {
186 self.changes_since(mem_name, target, None).map(|_| ())
190 }
191 MountStorage::Archive { .. } | MountStorage::InMemory => {
192 Err(invalid())
195 }
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use crate::storage::MemWriter;
203
204 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";
205
206 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
207 let dir = tmp.path().join("specs");
208 if !dir.exists() {
209 std::fs::create_dir_all(&dir).unwrap();
210 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
211 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
212 .unwrap();
213 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
214 crate::backend::MemBackend::append_provenance(
215 &writer,
216 &crate::provenance::Provenance::new(
217 std::time::SystemTime::now(),
218 crate::provenance::ProvenanceKind::Create,
219 Some("specs--seed".into()),
220 crate::vcs::Actor::Cli,
221 None,
222 None,
223 ),
224 )
225 .unwrap();
226 let config = memstead_schema::config::MemConfig {
229 name: None,
230 title: None,
231 subject: None,
232 version: None,
233 description: None,
234 authors: None,
235 schema: Some("default@1.0.0".parse().unwrap()),
236 write_guidance: Default::default(),
237 process_mem: None,
238 rules: None,
239 publish: None,
240 language: None,
241 read_mems: Default::default(),
242 community: None,
243 vcs: None,
244 unregistered_at: None,
245 sync_state: Default::default(),
246 review_mark: None,
247 mutation_stamp: None,
248 extra: Default::default(),
249 };
250 let meta = dir.join(memstead_schema::MEM_META_DIR);
251 std::fs::create_dir_all(&meta).unwrap();
252 std::fs::write(
253 meta.join("config.json"),
254 serde_json::to_vec_pretty(&config).unwrap(),
255 )
256 .unwrap();
257 }
258 let mount = crate::Mount {
259 mem: "specs".to_string(),
260 schema: Some(memstead_schema::SchemaRef::new(
261 "default",
262 semver::Version::new(1, 0, 0),
263 )),
264 storage: crate::MountStorage::Folder { path: dir.clone() },
265 capability: crate::MountCapability::Write,
266 lifecycle: crate::MountLifecycle::Eager,
267 cross_linkable: false,
268 migration_target: None,
269 };
270 let backend =
271 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
272 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
273 }
274
275 fn head(engine: &crate::Engine) -> String {
276 engine
277 .review_marks()
278 .into_iter()
279 .find(|s| s.mem == "specs")
280 .and_then(|s| s.head)
281 .expect("folder head cursor")
282 }
283
284 #[test]
285 fn mark_lifecycle_persists_validates_and_never_gates() {
286 let tmp = tempfile::TempDir::new().unwrap();
287 let mut engine = folder_engine(&tmp);
288
289 let status = &engine.review_marks()[0];
291 assert_eq!(status.mem, "specs");
292 assert!(status.mark.is_none());
293 assert!(status.writable);
294
295 let err = engine
297 .set_review_mark("specs", Some("not-a-timestamp"), None)
298 .unwrap_err();
299 assert_eq!(err.code(), "INVALID_CURSOR");
300 assert!(engine.review_marks()[0].mark.is_none());
301 let err = engine.set_review_mark("ghost", None, None).unwrap_err();
303 assert_eq!(err.code(), "UNKNOWN_MEM");
304
305 let err = engine.review_mark_diff("specs", None).unwrap_err();
307 assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
308
309 let reviewed = head(&engine);
311 let outcome = engine
312 .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
313 .unwrap();
314 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
315 assert!(outcome.previous.is_none());
316 let diff = engine.review_mark_diff("specs", None).unwrap();
317 assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
318
319 let outcome = engine
323 .create_entity(
324 crate::CreateEntityArgs {
325 mem: "specs".to_string(),
326 title: "Past The Mark".to_string(),
327 entity_type: "spec".to_string(),
328 sections: [
329 ("identity".to_string(), "x".to_string()),
330 ("purpose".to_string(), "y".to_string()),
331 ]
332 .into_iter()
333 .collect(),
334 metadata: Default::default(),
335 relations: Vec::new(),
336 anchors: Vec::new(),
337 dry_run: false,
338 },
339 crate::vcs::Actor::App,
340 None,
341 Some("agent work"),
342 )
343 .unwrap();
344 assert!(
345 outcome
346 .warnings
347 .iter()
348 .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
349 "marks never gate or warn: {:?}",
350 outcome.warnings
351 );
352 let diff = engine.review_mark_diff("specs", None).unwrap();
353 assert_eq!(diff.changes.len(), 1, "{diff:?}");
354 let direct = engine.changes_since("specs", &reviewed, None).unwrap();
355 assert_eq!(
356 serde_json::to_value(&diff.changes).unwrap(),
357 serde_json::to_value(&direct.changes).unwrap(),
358 "diff-since must equal changes_since at the mark"
359 );
360
361 drop(engine);
364 let mut second = folder_engine(&tmp);
365 assert_eq!(
366 second.review_marks()[0].mark.as_deref(),
367 Some(reviewed.as_str()),
368 "the mark is mem-repo state"
369 );
370
371 let outcome = second.set_review_mark("specs", None, None).unwrap();
373 assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
374 assert!(second.review_marks()[0].mark.is_none());
375 }
376
377 #[test]
378 fn noteless_set_under_require_notes_warns_and_commits() {
379 let tmp = tempfile::TempDir::new().unwrap();
380 let mut engine = folder_engine(&tmp);
381 engine.set_settings(crate::WorkspaceSettings {
382 mutations: crate::workspace::MutationsSection {
383 require_notes: Some(true),
384 },
385 ..Default::default()
386 });
387 let reviewed = head(&engine);
388 let outcome = engine
389 .set_review_mark("specs", Some(&reviewed), None)
390 .unwrap();
391 assert!(
392 outcome.warnings.iter().any(
393 |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
394 ),
395 "warn-and-commit: {:?}",
396 outcome.warnings
397 );
398 assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
399 }
400
401 #[test]
402 fn published_projection_strips_the_mark() {
403 let mut config = memstead_schema::config::MemConfig {
408 name: None,
409 title: None,
410 subject: None,
411 version: Some(semver::Version::new(1, 0, 0)),
412 description: None,
413 authors: None,
414 schema: Some("default@1.0.0".parse().unwrap()),
415 write_guidance: Default::default(),
416 process_mem: None,
417 rules: None,
418 publish: None,
419 language: None,
420 read_mems: Default::default(),
421 community: None,
422 vcs: None,
423 unregistered_at: None,
424 sync_state: Default::default(),
425 review_mark: None,
426 mutation_stamp: None,
427 extra: Default::default(),
428 };
429 config.review_mark = Some("deadbeef".to_string());
430 let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
431 let json = serde_json::to_string(&published).unwrap();
432 assert!(
433 !json.contains("reviewMark") && !json.contains("deadbeef"),
434 "published config must strip the mark: {json}"
435 );
436 }
437
438 #[test]
439 fn overview_roster_carries_the_mark_and_its_indicator() {
440 let tmp = tempfile::TempDir::new().unwrap();
445 let mut engine = folder_engine(&tmp);
446 let overview_md = |engine: &mut crate::Engine| {
447 crate::overview::compose_overview(
448 engine,
449 crate::overview::OverviewArgs {
450 include: &[],
451 mem: None,
452 rebuild: false,
453 token_budget: 8000,
454 operator_mode: false,
455 suppress_lifecycle: false,
456 },
457 crate::overview::Surface::Mcp,
458 )
459 .unwrap()
460 .markdown
461 };
462
463 let md = overview_md(&mut engine);
465 assert!(
466 !md.contains("Review mark"),
467 "markless roster must not mention marks: {md}"
468 );
469
470 let reviewed = head(&engine);
472 engine
473 .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
474 .unwrap();
475 let md = overview_md(&mut engine);
476 assert!(
477 md.contains(&format!("**Review mark:** `{reviewed}`")),
478 "roster must carry the mark value: {md}"
479 );
480 assert!(
481 md.contains("head is at the mark"),
482 "at-mark indicator missing: {md}"
483 );
484
485 engine
488 .create_entity(
489 crate::CreateEntityArgs {
490 mem: "specs".to_string(),
491 title: "Past The Mark Roster".to_string(),
492 entity_type: "spec".to_string(),
493 sections: [
494 ("identity".to_string(), "x".to_string()),
495 ("purpose".to_string(), "y".to_string()),
496 ]
497 .into_iter()
498 .collect(),
499 metadata: Default::default(),
500 relations: Vec::new(),
501 anchors: Vec::new(),
502 dry_run: false,
503 },
504 crate::vcs::Actor::App,
505 None,
506 Some("agent work"),
507 )
508 .unwrap();
509 let md = overview_md(&mut engine);
510 assert!(
511 md.contains("head has moved past the mark") && md.contains("changes_since"),
512 "unreviewed indicator missing: {md}"
513 );
514 }
515}