use std::path::PathBuf;
use memnite_cli::{
parse_relation, parse_scope, AddSpec, AnchorSpec, App, EventCtx, RelationSpec, SearchQuery,
UpdatePatch,
};
use crate::params::{
AddArgs, CheckArgs, ConflictsArgs, ContextArgs, DeleteArgs, GetArgs, RelateArgs, SearchArgs,
SessionSummaryArgs, TimelineArgs, UpdateArgs,
};
#[derive(Clone, Debug)]
pub struct Ctx {
pub ts: String,
pub engine: String,
pub machine: String,
}
impl Ctx {
fn to_event_ctx(&self) -> EventCtx {
EventCtx {
ts: self.ts.clone(),
engine: self.engine.clone(),
machine: self.machine.clone(),
}
}
}
type R = Result<String, memnite_cli::CliError>;
fn parse_anchor_arg(s: &str) -> Result<AnchorSpec, memnite_cli::CliError> {
memnite_cli::parse_anchor(s)
}
fn decay_mark(status: &str, mem_type: &str, updated_ts: &str, now: &str) -> String {
if status == "deleted" {
return String::new();
}
memnite_core::review_marker(mem_type, updated_ts, now)
.map(|s| format!(" [{s}]"))
.unwrap_or_default()
}
pub fn op_add(app: &App, a: AddArgs, ctx: &Ctx) -> R {
let scope = parse_scope(a.scope.as_deref().unwrap_or("agent"))?;
let mut anchors = Vec::with_capacity(a.anchors.len());
for raw in &a.anchors {
anchors.push(parse_anchor_arg(raw)?);
}
let root = a
.root
.map(PathBuf::from)
.unwrap_or(std::env::current_dir()?);
let project = a.project.unwrap_or_default();
let spec = AddSpec {
title: a.title,
body: a.body,
mem_type: a.r#type.unwrap_or_else(|| "decision".to_string()),
scope,
project: project.clone(),
topic_key: a.topic,
anchors,
tags: a.tags,
};
let row = app.add(spec, &root, &ctx.to_event_ctx())?;
let mut out = format!(
"created {} \"{}\" [{}]",
row.memory_id, row.title, row.status
);
if let Some(notice) = memnite_cli::normalization_notice(&project) {
out.push_str(&format!("\nwarning: {notice}"));
}
if let Ok(cands) = app.find_candidates(&row.memory_id, 3) {
if !cands.is_empty() {
out.push_str(&format!(
"\njudgment_required: {} candidate(s):",
cands.len()
));
for c in cands {
out.push_str(&format!("\n {}\t{}", c.memory_id, c.title));
}
}
}
Ok(out)
}
pub fn op_session_summary(app: &App, a: SessionSummaryArgs, ctx: &Ctx) -> R {
let scope = parse_scope(a.scope.as_deref().unwrap_or("agent"))?;
let row = app.session_summary(
a.summary,
a.project.unwrap_or_default(),
scope,
&ctx.to_event_ctx(),
)?;
Ok(format!(
"saved session summary {} [{}]",
row.memory_id, row.status
))
}
pub fn op_search(app: &App, a: SearchArgs) -> R {
let scope = match a.scope.as_deref() {
Some(s) => Some(parse_scope(s)?),
None => None,
};
let q = SearchQuery {
text: a.query,
mem_type: a.r#type,
project: a.project,
scope,
match_any: a.match_any,
};
let rows = app.search(q)?;
if rows.is_empty() {
return Ok("0 results".to_string());
}
let now = chrono::Utc::now().to_rfc3339();
let body = rows
.iter()
.map(|m| {
format!(
"{}\t{}\t[{}]{}{}",
m.memory_id,
m.title,
m.status,
annotate(app, &m.memory_id),
decay_mark(&m.status, &m.mem_type, &m.updated_ts, &now)
)
})
.collect::<Vec<_>>()
.join("\n");
Ok(format!("{} results\n{}", rows.len(), body))
}
pub fn op_get(app: &App, a: GetArgs) -> R {
match app.get(&a.memory_id)? {
Some(m) => {
let now = chrono::Utc::now().to_rfc3339();
Ok(format!(
"{}\t{}\t[{}]{}\n{}",
m.memory_id,
m.title,
m.status,
decay_mark(&m.status, &m.mem_type, &m.updated_ts, &now),
m.body
))
}
None => Ok("(not found)".to_string()),
}
}
pub fn op_stale(app: &App) -> R {
let rows = app.list_stale()?;
if rows.is_empty() {
return Ok("no stale memories".to_string());
}
Ok(rows
.iter()
.map(|m| format!("{}\t{}", m.memory_id, m.title))
.collect::<Vec<_>>()
.join("\n"))
}
pub fn op_check(app: &App, a: CheckArgs, ctx: &Ctx) -> R {
let root = a
.root
.map(PathBuf::from)
.unwrap_or(std::env::current_dir()?);
let s = app.check(&root, &ctx.to_event_ctx())?;
Ok(format!(
"checked: {} stable, {} stale, {} unchanged",
s.stable, s.stale, s.unchanged
))
}
pub fn op_rebuild(app: &App) -> R {
let n = app.rebuild()?;
Ok(format!("rebuilt projection from {n} events"))
}
pub fn op_update(app: &App, a: UpdateArgs, ctx: &Ctx) -> R {
let scope = match a.scope.as_deref() {
Some(s) => Some(parse_scope(s)?),
None => None,
};
let anchors = match a.anchors {
Some(raws) => {
let mut out = Vec::with_capacity(raws.len());
for raw in &raws {
out.push(parse_anchor_arg(raw)?);
}
Some(out)
}
None => None,
};
let patch = UpdatePatch {
title: a.title,
body: a.body,
mem_type: a.r#type,
scope,
project: a.project,
topic_key: a.topic,
anchors,
tags: a.tags,
};
let root = a
.root
.map(PathBuf::from)
.unwrap_or(std::env::current_dir()?);
let row = app.update(&a.memory_id, patch, &root, &ctx.to_event_ctx())?;
Ok(format!(
"updated {} \"{}\" [{}]",
row.memory_id, row.title, row.status
))
}
pub fn op_delete(app: &App, a: DeleteArgs, ctx: &Ctx) -> R {
app.delete(&a.memory_id, &ctx.to_event_ctx())?;
Ok(format!("deleted {}", a.memory_id))
}
pub fn op_context(app: &App, a: ContextArgs) -> R {
let rows = app.context(&a.project, a.limit.unwrap_or(20))?;
if rows.is_empty() {
return Ok("no memories".to_string());
}
let now = chrono::Utc::now().to_rfc3339();
Ok(rows
.iter()
.map(|m| {
format!(
"{}\t{}\t[{}]{}",
m.memory_id,
m.title,
m.status,
decay_mark(&m.status, &m.mem_type, &m.updated_ts, &now)
)
})
.collect::<Vec<_>>()
.join("\n"))
}
pub fn op_timeline(app: &App, a: TimelineArgs) -> R {
let rows = app.timeline(&a.memory_id)?;
if rows.is_empty() {
return Ok("no events".to_string());
}
Ok(rows
.iter()
.map(|e| format!("{}\t{}\t{}\t{}", e.lamport, e.kind, e.ts, e.engine))
.collect::<Vec<_>>()
.join("\n"))
}
pub fn op_doctor(app: &App) -> R {
let r = app.doctor()?;
let mut lines: Vec<String> = if r.mismatches.is_empty() {
vec!["ok".to_string()]
} else {
r.mismatches.clone()
};
for c in &r.conflicts {
lines.push(format!(
"conflict {}: {} — {} (L{}) overwritten by {} (L{})",
c.memory_id,
c.field,
c.lost_writer,
c.lost_lamport,
c.winning_writer,
c.winning_lamport
));
}
Ok(format!(
"log={} proj={} cursor_ok={} mismatches={} conflicts={}\n{}",
r.log_count,
r.proj_count,
r.cursor_ok,
r.mismatches.len(),
r.conflicts.len(),
lines.join("\n")
))
}
pub fn op_relate(app: &App, a: RelateArgs, ctx: &Ctx) -> R {
let relation = parse_relation(&a.relation)?;
let spec = RelationSpec {
relation,
confidence: a.confidence.unwrap_or(1.0),
reason: a.reason.unwrap_or_default(),
judged_by: "agent".to_string(),
};
app.relate(&a.from_id, &a.to_id, spec, &ctx.to_event_ctx())?;
Ok(format!("related {} -> {}", a.from_id, a.to_id))
}
pub fn op_conflicts(app: &App, a: ConflictsArgs) -> R {
let rels = app.relations_for(&a.memory_id)?;
if rels.is_empty() {
return Ok("no relations".to_string());
}
Ok(rels
.iter()
.map(|r| {
format!(
"{}\t{}\t{}\t{:.2}\t{}",
r.from_id, r.relation, r.to_id, r.confidence, r.judged_by
)
})
.collect::<Vec<_>>()
.join("\n"))
}
fn annotate(app: &App, memory_id: &str) -> String {
let rels = match app.relations_for(memory_id) {
Ok(r) => r,
Err(_) => return String::new(),
};
let mut parts = Vec::new();
for r in rels {
if r.from_id == memory_id {
parts.push(format!("{} {}", r.relation, r.to_id));
} else if r.relation == "supersedes" {
parts.push(format!("superseded_by {}", r.from_id));
} else {
parts.push(format!("{} {}", r.relation, r.from_id));
}
}
if parts.is_empty() {
String::new()
} else {
format!(" [{}]", parts.join("; "))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Ctx {
Ctx {
ts: "2026-06-27T00:00:00Z".to_string(),
engine: "mcp".to_string(),
machine: "test".to_string(),
}
}
#[test]
fn add_then_update_then_delete_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let added = op_add(
&app,
AddArgs {
title: "t".to_string(),
body: "b".to_string(),
r#type: None,
scope: None,
project: Some("p".to_string()),
topic: None,
tags: vec![],
anchors: vec![],
root: Some(dir.path().to_string_lossy().to_string()),
},
&ctx(),
)
.unwrap();
assert!(added.starts_with("created mem_"));
let listed = op_context(
&app,
ContextArgs {
project: "p".to_string(),
limit: None,
},
)
.unwrap();
let id = listed.split('\t').next().unwrap().to_string();
let updated = op_update(
&app,
UpdateArgs {
memory_id: id.clone(),
title: Some("t2".to_string()),
body: None,
r#type: None,
scope: None,
project: None,
topic: None,
tags: None,
anchors: None,
root: None,
},
&ctx(),
)
.unwrap();
assert!(updated.contains("\"t2\""));
let deleted = op_delete(
&app,
DeleteArgs {
memory_id: id.clone(),
},
&ctx(),
)
.unwrap();
assert_eq!(deleted, format!("deleted {id}"));
}
#[test]
fn add_rejects_bogus_scope() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let err = op_add(
&app,
AddArgs {
title: "t".to_string(),
body: "b".to_string(),
r#type: None,
scope: Some("bogus".to_string()),
project: None,
topic: None,
tags: vec![],
anchors: vec![],
root: Some(dir.path().to_string_lossy().to_string()),
},
&ctx(),
);
assert!(err.is_err());
}
#[test]
fn relate_then_conflicts_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
let mk = |title: &str| {
let out = op_add(
&app,
AddArgs {
title: title.to_string(),
body: "b".to_string(),
r#type: None,
scope: None,
project: Some("p".to_string()),
topic: None,
tags: vec![],
anchors: vec![],
root: Some(root.clone()),
},
&ctx(),
)
.unwrap();
out.split_whitespace().nth(1).unwrap().to_string()
};
let a = mk("payments via stripe");
let b = mk("payments via paypal");
let r = op_relate(
&app,
RelateArgs {
from_id: a.clone(),
to_id: b.clone(),
relation: "conflicts_with".to_string(),
confidence: None,
reason: Some("different processor".to_string()),
},
&ctx(),
)
.unwrap();
assert!(r.contains("related"));
let listed = op_conflicts(
&app,
ConflictsArgs {
memory_id: a.clone(),
},
)
.unwrap();
assert!(listed.contains("conflicts_with"));
assert!(listed.contains(&b));
}
fn add_mem(app: &App, root: &str, title: &str) -> String {
let out = op_add(
app,
AddArgs {
title: title.to_string(),
body: "b".to_string(),
r#type: None,
scope: None,
project: Some("p".to_string()),
topic: None,
tags: vec![],
anchors: vec![],
root: Some(root.to_string()),
},
&ctx(),
)
.unwrap();
out.split_whitespace().nth(1).unwrap().to_string()
}
#[test]
fn search_annotates_supersedes_both_directions() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
let a = add_mem(&app, &root, "payments via stripe");
let b = add_mem(&app, &root, "payments via paypal");
op_relate(
&app,
RelateArgs {
from_id: a.clone(),
to_id: b.clone(),
relation: "supersedes".to_string(),
confidence: None,
reason: None,
},
&ctx(),
)
.unwrap();
let results = op_search(
&app,
SearchArgs {
query: "payments".to_string(),
..Default::default()
},
)
.unwrap();
assert!(
results.contains("supersedes"),
"search must annotate source"
);
assert!(
results.contains("superseded_by"),
"search must annotate target"
);
}
#[test]
fn add_surfaces_candidate_for_similar_title() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
add_mem(&app, &root, "payments via stripe");
let out = op_add(
&app,
AddArgs {
title: "payments via paypal".to_string(),
body: "b".to_string(),
r#type: None,
scope: None,
project: Some("p".to_string()),
topic: None,
tags: vec![],
anchors: vec![],
root: Some(root.clone()),
},
&ctx(),
)
.unwrap();
assert!(
out.contains("judgment_required"),
"op_add must surface candidates: {out}"
);
}
fn add_args(project: &str, topic: Option<&str>, root: &str) -> AddArgs {
AddArgs {
title: "t".to_string(),
body: "b".to_string(),
r#type: None,
scope: None,
project: Some(project.to_string()),
topic: topic.map(str::to_string),
tags: vec![],
anchors: vec![],
root: Some(root.to_string()),
}
}
#[test]
fn add_warns_when_explicit_project_not_canonical() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
let out = op_add(&app, add_args("My-Repo", None, &root), &ctx()).unwrap();
assert!(out.contains("warning:"), "expected warning: {out}");
assert!(
out.contains("my-repo"),
"warning must show canonical form: {out}"
);
}
#[test]
fn add_no_warning_when_project_already_canonical() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
let out = op_add(&app, add_args("my-repo", None, &root), &ctx()).unwrap();
assert!(!out.contains("warning:"), "no warning expected: {out}");
}
#[test]
fn add_variants_dedup_to_one_bucket_via_mcp() {
let dir = tempfile::tempdir().unwrap();
let app = App::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().to_string();
op_add(&app, add_args("My-Repo", Some("k"), &root), &ctx()).unwrap();
op_add(&app, add_args("my-repo", Some("k"), &root), &ctx()).unwrap();
let listed = op_context(
&app,
ContextArgs {
project: "my-repo".to_string(),
limit: None,
},
)
.unwrap();
assert_eq!(listed.lines().count(), 1, "variants must dedup: {listed}");
}
}