Skip to main content

memstead_cli/commands/
review_mark.rs

1//! `memstead review-mark` — read and move the per-mem review mark.
2//!
3//! The mark is the engine's one pointer per mem to the last
4//! human-approved state (mem-repo state: every consumer of the mem
5//! sees the same mark). `list` answers "what has un-reviewed
6//! changes"; `set` moves a mem's mark to an explicitly named state
7//! (never implicitly "now" — writers may have advanced the mem
8//! mid-review); `clear` returns a mem to the ordinary markless
9//! state; `diff` reports the accumulated per-entity delta from the
10//! mark to the current head, in the same change-envelope shape
11//! `memstead changes` uses. Marks never gate writes — ignoring them
12//! entirely is a first-class state.
13
14use clap::{Args as ClapArgs, Parser, Subcommand};
15
16use crate::CliError;
17use crate::output::{print_json, print_markdown};
18use crate::setup::CliContext;
19
20#[derive(Parser, Debug)]
21pub struct Args {
22    #[command(subcommand)]
23    pub action: ReviewMarkAction,
24}
25
26#[derive(Subcommand, Debug)]
27pub enum ReviewMarkAction {
28    /// Every mem's mark (or its absence) alongside the current head.
29    /// A mem with no mark is an ordinary state, not a warning.
30    List,
31    /// Set a mem's mark to an explicitly named state — the state the
32    /// review actually covered. The value is a backend cursor: a
33    /// commit SHA for git-branch mems, an RFC 3339 timestamp for
34    /// folder mems (the same cursor `memstead changes --since`
35    /// consumes; `list` shows each mem's current head in that
36    /// vocabulary). An invalid cursor refuses with `INVALID_CURSOR`
37    /// and leaves the mark untouched.
38    Set(SetArgs),
39    /// Clear a mem's mark, returning it to the markless state.
40    Clear(ClearArgs),
41    /// The accumulated per-entity delta from the mem's mark to its
42    /// current head. Refuses with `REVIEW_MARK_NOT_SET` on a markless
43    /// mem — marklessness is visible in `list`, never silently
44    /// equated with "no changes".
45    Diff(DiffArgs),
46}
47
48#[derive(ClapArgs, Debug)]
49pub struct SetArgs {
50    /// Writable mem name.
51    pub mem: String,
52    /// The reviewed state (backend cursor — see `set --help`).
53    pub state: String,
54    /// Provenance note (≤280 chars). Under `[mutations].require_notes`
55    /// a missing note adds a `NOTE_MISSING` warning; the write still
56    /// commits (warn-and-commit, like every note-gated mutation).
57    #[arg(long)]
58    pub note: Option<String>,
59}
60
61#[derive(ClapArgs, Debug)]
62pub struct ClearArgs {
63    /// Writable mem name.
64    pub mem: String,
65    /// Provenance note (≤280 chars).
66    #[arg(long)]
67    pub note: Option<String>,
68}
69
70#[derive(ClapArgs, Debug)]
71pub struct DiffArgs {
72    /// Mem name.
73    pub mem: String,
74    /// Rename detection threshold in [0.1, 1.0]; mirrors
75    /// `memstead changes --rename-similarity`. Git-branch mems only —
76    /// folder mems have no rename detection (renames surface as
77    /// updates).
78    #[arg(long)]
79    pub rename_similarity: Option<f32>,
80}
81
82pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
83    let mut engine = ctx.cli_engine()?.into_base();
84    match args.action {
85        ReviewMarkAction::List => {
86            let marks = engine.review_marks();
87            if ctx.json {
88                print_json(&serde_json::json!({ "marks": marks }))?;
89                return Ok(());
90            }
91            let mut lines = vec!["# Review marks".to_string(), String::new()];
92            if marks.is_empty() {
93                lines.push("_no mems loaded_".to_string());
94            }
95            for s in &marks {
96                let head = s.head.as_deref().unwrap_or("(no head)");
97                let line = match s.mark.as_deref() {
98                    None => format!("- `{}` — no mark (head `{head}`)", s.mem),
99                    Some(mark) if Some(mark) == s.head.as_deref() => {
100                        format!("- `{}` — mark `{mark}` at head (nothing unreviewed)", s.mem)
101                    }
102                    Some(mark) => format!(
103                        "- `{}` — mark `{mark}`, head `{head}` (unreviewed changes — `memstead review-mark diff {}`)",
104                        s.mem, s.mem
105                    ),
106                };
107                lines.push(line);
108            }
109            print_markdown(&lines.join("\n"));
110            Ok(())
111        }
112        ReviewMarkAction::Set(a) => {
113            let outcome = engine
114                .set_review_mark(&a.mem, Some(&a.state), a.note.as_deref())
115                .map_err(CliError::from_engine_op)?;
116            report_outcome(ctx, &outcome, "set")
117        }
118        ReviewMarkAction::Clear(a) => {
119            let outcome = engine
120                .set_review_mark(&a.mem, None, a.note.as_deref())
121                .map_err(CliError::from_engine_op)?;
122            report_outcome(ctx, &outcome, "cleared")
123        }
124        ReviewMarkAction::Diff(a) => {
125            let report = engine
126                .review_mark_diff(&a.mem, a.rename_similarity)
127                .map_err(CliError::from_engine_op)?;
128            if ctx.json {
129                print_json(&report)?;
130                return Ok(());
131            }
132            let mut lines = vec![
133                format!(
134                    "# Unreviewed changes in `{}` since mark `{}`",
135                    report.mem, report.since
136                ),
137                String::new(),
138                format!("- HEAD: `{}`", report.head),
139                format!("- Changes: {}", report.changes.len()),
140                String::new(),
141            ];
142            if report.changes.is_empty() {
143                lines.push("_head is at the mark — nothing unreviewed_".to_string());
144            } else {
145                for change in &report.changes {
146                    use memstead_base::ChangeEnvelope::*;
147                    let type_suffix = |t: &Option<String>| {
148                        t.as_ref().map(|s| format!(" [{s}]")).unwrap_or_default()
149                    };
150                    let title_suffix = |t: &Option<String>| {
151                        t.as_ref().map(|s| format!(" — {s}")).unwrap_or_default()
152                    };
153                    lines.push(match change {
154                        Added {
155                            id,
156                            title,
157                            entity_type,
158                        } => format!(
159                            "- **added** `{id}`{}{}",
160                            type_suffix(entity_type),
161                            title_suffix(title)
162                        ),
163                        Updated {
164                            id,
165                            title,
166                            entity_type,
167                        } => format!(
168                            "- **updated** `{id}`{}{}",
169                            type_suffix(entity_type),
170                            title_suffix(title)
171                        ),
172                        Removed {
173                            id,
174                            title,
175                            entity_type,
176                        } => format!(
177                            "- **removed** `{id}`{}{}",
178                            type_suffix(entity_type),
179                            title_suffix(title)
180                        ),
181                        Renamed {
182                            from_id,
183                            to_id,
184                            title,
185                            entity_type,
186                        } => format!(
187                            "- **renamed** `{from_id}` → `{to_id}`{}{}",
188                            type_suffix(entity_type),
189                            title_suffix(title)
190                        ),
191                    });
192                }
193            }
194            print_markdown(&lines.join("\n"));
195            Ok(())
196        }
197    }
198}
199
200fn report_outcome(
201    ctx: &CliContext,
202    outcome: &memstead_base::SetReviewMarkOutcome,
203    verb: &str,
204) -> anyhow::Result<()> {
205    if ctx.json {
206        print_json(outcome)?;
207        return Ok(());
208    }
209    let mut lines = vec![match outcome.mark.as_deref() {
210        Some(mark) => format!("Review mark {verb} on `{}`: `{mark}`", outcome.mem),
211        None => format!("Review mark {verb} on `{}`", outcome.mem),
212    }];
213    if let Some(prev) = outcome.previous.as_deref() {
214        lines.push(format!("- previous: `{prev}`"));
215    }
216    for w in &outcome.warnings {
217        lines.push(format!("- warning: {w}"));
218    }
219    print_markdown(&lines.join("\n"));
220    Ok(())
221}