Skip to main content

memnite_cli/
spec.rs

1use memnite_core::Conflict;
2use memnite_core::Relation;
3use memnite_core::Scope;
4
5/// One anchor to attach to a new memory (hash is computed from the file at add-time).
6#[derive(Clone, Debug)]
7pub struct AnchorSpec {
8    pub path: String,
9    pub symbol: Option<String>,
10    pub line_start: u32,
11    pub line_end: u32,
12}
13
14/// Inputs to create a memory.
15#[derive(Clone, Debug)]
16pub struct AddSpec {
17    pub title: String,
18    pub body: String,
19    pub mem_type: String,
20    pub scope: memnite_core::Scope,
21    pub project: String,
22    pub topic_key: Option<String>,
23    pub anchors: Vec<AnchorSpec>,
24    pub tags: Vec<String>,
25}
26
27/// Inputs to assert a relation between two memories. The direction (`from → to`)
28/// is carried positionally by `App::relate`; this holds the classification.
29#[derive(Clone, Debug)]
30pub struct RelationSpec {
31    pub relation: Relation,
32    pub confidence: f32,
33    pub reason: String,
34    pub judged_by: String,
35}
36
37/// Per-event context the caller supplies (kept out of `App` so tests are
38/// deterministic and `main` injects the real wall-clock/host).
39#[derive(Clone, Debug)]
40pub struct EventCtx {
41    pub ts: String,
42    pub engine: String,
43    pub machine: String,
44}
45
46/// Result of a `check` run.
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct CheckSummary {
49    pub stable: usize,
50    pub stale: usize,
51    pub unchanged: usize,
52}
53
54/// Fields to change on an existing memory. `None` = leave unchanged.
55/// Note: unsetting `topic_key` is not supported in v1 — `Some(x)` sets it,
56/// `None` leaves the current value. An empty `anchors`/`tags` `Vec` inside
57/// `Some` replaces the current value with empty.
58#[derive(Clone, Debug, Default)]
59pub struct UpdatePatch {
60    pub title: Option<String>,
61    pub body: Option<String>,
62    pub mem_type: Option<String>,
63    pub scope: Option<memnite_core::Scope>,
64    pub project: Option<String>,
65    pub topic_key: Option<String>,
66    pub anchors: Option<Vec<AnchorSpec>>,
67    pub tags: Option<Vec<String>>,
68}
69
70/// A search request: free text plus optional exact filters and match mode.
71#[derive(Debug, Default)]
72pub struct SearchQuery {
73    pub text: String,
74    pub mem_type: Option<String>,
75    pub project: Option<String>,
76    pub scope: Option<Scope>,
77    /// false = AND between tokens (default); true = OR (broaden recall).
78    pub match_any: bool,
79}
80
81impl From<&str> for SearchQuery {
82    fn from(text: &str) -> Self {
83        SearchQuery {
84            text: text.to_string(),
85            ..Default::default()
86        }
87    }
88}
89
90/// Result of `doctor`: integrity of the projection vs. the log. Compares only
91/// projected fields (status, title, last_event_id); `tags` are not projected so
92/// they are not compared.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct DoctorReport {
95    pub log_count: usize,
96    pub proj_count: usize,
97    pub mismatches: Vec<String>,
98    pub cursor_ok: bool,
99    pub conflicts: Vec<Conflict>,
100}
101
102/// Result of a `sync import`: how many events were newly appended, skipped as
103/// already-present duplicates, or skipped as corrupt (unparseable) lines.
104#[derive(Clone, Debug, Default, PartialEq, Eq)]
105pub struct ImportSummary {
106    pub imported: usize,
107    pub duplicates: usize,
108    pub corrupt: usize,
109}
110
111/// Result of a `conflicts scan`: pairs judged, edges asserted, not-conflict
112/// verdicts, and judge errors (each swallowed — a failing pair never aborts).
113/// `first_error` samples the first judge error so a total misconfiguration
114/// (every pair errors) is distinguishable from "ran clean, found nothing".
115#[derive(Clone, Debug, Default, PartialEq)]
116pub struct ScanSummary {
117    pub pairs: usize,
118    pub asserted: usize,
119    pub not_conflict: usize,
120    pub errors: usize,
121    pub first_error: Option<String>,
122}
123
124use crate::error::CliError;
125
126/// Parse a relation label at a boundary. Rejects unknown values.
127pub fn parse_relation(s: &str) -> Result<Relation, CliError> {
128    Relation::from_label(s).ok_or_else(|| CliError::Invalid(format!("unknown relation {s:?}")))
129}
130
131/// Parse a scope string at a boundary. Rejects unknown values (no silent fallback).
132pub fn parse_scope(s: &str) -> Result<memnite_core::Scope, CliError> {
133    match s {
134        "agent" => Ok(memnite_core::Scope::Agent),
135        "repo" => Ok(memnite_core::Scope::Repo),
136        "global" => Ok(memnite_core::Scope::Global),
137        other => Err(CliError::Invalid(format!(
138            "scope must be 'agent', 'repo', or 'global', got {other:?}"
139        ))),
140    }
141}
142
143/// Parse a CLI anchor argument: `path:start:end` or `path:start:end:symbol`.
144pub fn parse_anchor(s: &str) -> Result<AnchorSpec, CliError> {
145    let parts: Vec<&str> = s.splitn(4, ':').collect();
146    if parts.len() < 3 {
147        return Err(CliError::Invalid(format!(
148            "anchor must be path:start:end[:symbol], got {s:?}"
149        )));
150    }
151    let line_start = parts[1]
152        .parse::<u32>()
153        .map_err(|_| CliError::Invalid(format!("anchor start not a number: {:?}", parts[1])))?;
154    let line_end = parts[2]
155        .parse::<u32>()
156        .map_err(|_| CliError::Invalid(format!("anchor end not a number: {:?}", parts[2])))?;
157    Ok(AnchorSpec {
158        path: parts[0].to_string(),
159        symbol: parts.get(3).map(|s| s.to_string()),
160        line_start,
161        line_end,
162    })
163}