1use std::path::{Path, PathBuf};
23
24use clap::{Args, Subcommand};
25use serde::Serialize;
26use serde_json::{Map, Value};
27
28use memstead_git_branch::mem_repo_config::branch_ref_for_mem_at_gitdir;
29
30use crate::CliError;
31use crate::output::{ExitKind, print_json};
32use crate::setup::{CliContext, find_workspace_root, workspace_not_initialised_error};
33use memstead_engine::workspace_config_edit::{
34 self, CrossLinkTarget, WorkspaceEditError, WorkspaceEditWarning,
35};
36
37#[derive(Subcommand, Debug)]
39pub enum WorkspaceAction {
40 Dump(DumpArgs),
44
45 Show(ShowArgs),
52
53 #[command(name = "allow-create")]
60 AllowCreate(AllowCreateArgs),
61
62 #[command(name = "revoke-create")]
64 RevokeCreate(PatternArg),
65
66 #[command(name = "allow-delete")]
68 AllowDelete(PatternArg),
69
70 #[command(name = "revoke-delete")]
72 RevokeDelete(PatternArg),
73
74 #[command(name = "grant-cross-link")]
79 GrantCrossLink(CrossLinkArgs),
80
81 #[command(name = "revoke-cross-link")]
85 RevokeCrossLink(CrossLinkArgs),
86
87 #[command(name = "set-mutations")]
90 SetMutations(SetMutationsArgs),
91}
92
93#[derive(Args, Debug)]
96pub struct DumpArgs {}
97
98#[derive(Args, Debug)]
101pub struct ShowArgs {}
102
103#[derive(Args, Debug)]
105pub struct AllowCreateArgs {
106 pub pattern: String,
110
111 #[arg(long, required = true, value_delimiter = ',')]
114 pub schema: Vec<String>,
115
116 #[arg(long, value_delimiter = ',')]
122 pub cross_link: Vec<String>,
123
124 #[arg(long)]
128 pub before: Option<String>,
129}
130
131#[derive(Args, Debug)]
133pub struct PatternArg {
134 pub pattern: String,
136}
137
138#[derive(Args, Debug)]
140pub struct CrossLinkArgs {
141 pub from: String,
143 pub to: String,
145}
146
147#[derive(Args, Debug)]
149pub struct SetMutationsArgs {
150 #[arg(long, value_name = "BOOL", value_parser = clap::value_parser!(bool))]
154 pub require_notes: Option<bool>,
155}
156
157pub fn run(ctx: &CliContext, action: WorkspaceAction) -> anyhow::Result<()> {
158 match action {
159 WorkspaceAction::Dump(args) => dump(ctx, args),
160 WorkspaceAction::Show(args) => show(ctx, args),
161 WorkspaceAction::AllowCreate(args) => allow_create(ctx, args),
162 WorkspaceAction::RevokeCreate(args) => revoke_create(ctx, args),
163 WorkspaceAction::AllowDelete(args) => allow_delete(ctx, args),
164 WorkspaceAction::RevokeDelete(args) => revoke_delete(ctx, args),
165 WorkspaceAction::GrantCrossLink(args) => grant_cross_link(ctx, args),
166 WorkspaceAction::RevokeCrossLink(args) => revoke_cross_link(ctx, args),
167 WorkspaceAction::SetMutations(args) => set_mutations(ctx, args),
168 }
169}
170
171fn require_workspace_root() -> anyhow::Result<PathBuf> {
175 let cwd = std::env::current_dir().map_err(|e| CliError {
176 code: crate::INTERNAL_CODE,
177 kind: ExitKind::Generic,
178 message: format!("could not determine current directory: {e}"),
179 details: None,
180 })?;
181 find_workspace_root(&cwd).ok_or_else(|| {
182 workspace_not_initialised_error(
183 "No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` or `memstead init` to bootstrap).",
184 )
185 .into()
186 })
187}
188
189fn lift_edit_error(err: WorkspaceEditError) -> CliError {
196 let code = err.code();
197 let message = err.to_string();
198 let (kind, details) = match &err {
201 WorkspaceEditError::WorkspaceNotInitialised { .. } => (ExitKind::Generic, None),
202 WorkspaceEditError::InvalidToml { .. } => (ExitKind::Validation, None),
203 WorkspaceEditError::BeforePatternNotFound { .. } => (ExitKind::NotFound, None),
204 WorkspaceEditError::CrossLinkConflict { .. } => (ExitKind::Validation, None),
205 WorkspaceEditError::RuleExistsSchemasDiffer {
206 section,
207 pattern,
208 stored,
209 requested,
210 } => (
211 ExitKind::Validation,
212 Some(serde_json::json!({
213 "section": section,
214 "pattern": pattern,
215 "stored_schemas": stored,
216 "requested_schemas": requested,
217 "recovery": format!(
218 "revoke-create {pattern} then allow-create {pattern} --schema … with the new schemas"
219 ),
220 })),
221 ),
222 WorkspaceEditError::Io { .. } => (ExitKind::Generic, None),
223 };
224 CliError {
228 kind,
229 code,
230 message,
231 details,
232 }
233}
234
235fn emit_warnings_stderr(warnings: &[WorkspaceEditWarning]) {
245 for w in warnings {
246 eprintln!("warning [{}]: {}", w.code(), w);
247 }
248}
249
250fn parse_schemas(schemas: &[String]) -> Vec<String> {
251 schemas.to_vec()
255}
256
257fn parse_cross_links(targets: &[String]) -> Vec<CrossLinkTarget> {
258 targets.iter().map(|t| CrossLinkTarget::parse(t)).collect()
259}
260
261fn confirm_block(
274 ctx: &CliContext,
275 action: &str,
276 detail: serde_json::Value,
277 heading: &str,
278 bullets: Vec<String>,
279 warnings: &[WorkspaceEditWarning],
280) -> anyhow::Result<()> {
281 if ctx.json {
282 emit_warnings_stderr(warnings);
283 let payload = serde_json::json!({ "action": action, "detail": detail });
284 return print_json(&payload);
285 }
286 let mut lines: Vec<String> = Vec::with_capacity(2 + bullets.len() + 2 * warnings.len());
287 lines.push(format!("# {heading}"));
288 lines.push(String::new());
289 for b in bullets {
290 lines.push(format!("- {b}"));
291 }
292 if !warnings.is_empty() {
293 lines.push(String::new());
294 lines.push("## Warnings".to_string());
295 lines.push(String::new());
296 for w in warnings {
297 lines.push(format!("- **{}**: {}", w.code(), w));
298 }
299 }
300 crate::output::print_markdown(&lines.join("\n"));
301 Ok(())
302}
303
304fn render_list_inline(items: &[String]) -> String {
310 if items.is_empty() {
311 "(none)".to_string()
312 } else {
313 format!("[{}]", items.join(", "))
314 }
315}
316
317fn allow_create(ctx: &CliContext, args: AllowCreateArgs) -> anyhow::Result<()> {
318 let root = require_workspace_root()?;
319 let schemas = parse_schemas(&args.schema);
320 let cross_links = parse_cross_links(&args.cross_link);
321 let cross_links_opt = if cross_links.is_empty() {
322 None
323 } else {
324 Some(cross_links.as_slice())
325 };
326 let warnings = workspace_config_edit::add_create_rule(
327 &root,
328 &args.pattern,
329 &schemas,
330 cross_links_opt,
331 args.before.as_deref(),
332 )
333 .map_err(lift_edit_error)?;
334 let heading = format!("Workspace allow-create rule `{}`", args.pattern);
335 let position = args
336 .before
337 .as_deref()
338 .map(|p| format!("Position: before `{p}`"))
339 .unwrap_or_else(|| "Position: appended (lowest priority)".to_string());
340 let bullets = vec![
341 format!("Pattern: `{}`", args.pattern),
342 format!("Schemas: {}", render_list_inline(&schemas)),
343 format!(
344 "Default cross-links: {}",
345 render_list_inline(&args.cross_link)
346 ),
347 position,
348 ];
349 confirm_block(
350 ctx,
351 "allow-create",
352 serde_json::json!({
353 "pattern": args.pattern,
354 "schemas": schemas,
355 "before": args.before,
356 "cross_links": args.cross_link,
357 }),
358 &heading,
359 bullets,
360 &warnings,
361 )
362}
363
364fn revoke_create(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
365 let root = require_workspace_root()?;
366 let warnings =
367 workspace_config_edit::remove_create_rule(&root, &args.pattern).map_err(lift_edit_error)?;
368 let heading = format!("Workspace revoke-create rule `{}`", args.pattern);
369 let bullets = vec![format!("Pattern: `{}`", args.pattern)];
370 confirm_block(
371 ctx,
372 "revoke-create",
373 serde_json::json!({ "pattern": args.pattern }),
374 &heading,
375 bullets,
376 &warnings,
377 )
378}
379
380fn allow_delete(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
381 let root = require_workspace_root()?;
382 let warnings =
383 workspace_config_edit::add_delete_rule(&root, &args.pattern).map_err(lift_edit_error)?;
384 let heading = format!("Workspace allow-delete rule `{}`", args.pattern);
385 let bullets = vec![format!("Pattern: `{}`", args.pattern)];
386 confirm_block(
387 ctx,
388 "allow-delete",
389 serde_json::json!({ "pattern": args.pattern }),
390 &heading,
391 bullets,
392 &warnings,
393 )
394}
395
396fn revoke_delete(ctx: &CliContext, args: PatternArg) -> anyhow::Result<()> {
397 let root = require_workspace_root()?;
398 let warnings =
399 workspace_config_edit::remove_delete_rule(&root, &args.pattern).map_err(lift_edit_error)?;
400 let heading = format!("Workspace revoke-delete rule `{}`", args.pattern);
401 let bullets = vec![format!("Pattern: `{}`", args.pattern)];
402 confirm_block(
403 ctx,
404 "revoke-delete",
405 serde_json::json!({ "pattern": args.pattern }),
406 &heading,
407 bullets,
408 &warnings,
409 )
410}
411
412fn grant_cross_link(ctx: &CliContext, args: CrossLinkArgs) -> anyhow::Result<()> {
413 let root = require_workspace_root()?;
414 let known_mems: Vec<String> = {
418 let engine = crate::setup::full_engine(ctx)?;
419 engine.mem_names().iter().map(|s| s.to_string()).collect()
420 };
421 let target = CrossLinkTarget::parse(&args.to);
422 let warnings = workspace_config_edit::grant_cross_link(&root, &args.from, &target, &known_mems)
423 .map_err(lift_edit_error)?;
424 let heading = format!("Workspace grant-cross-link `{}` → `{}`", args.from, args.to);
425 let bullets = vec![
426 format!("From: `{}`", args.from),
427 format!("To: `{}`", args.to),
428 ];
429 confirm_block(
430 ctx,
431 "grant-cross-link",
432 serde_json::json!({ "from": args.from, "to": args.to }),
433 &heading,
434 bullets,
435 &warnings,
436 )
437}
438
439fn revoke_cross_link(ctx: &CliContext, args: CrossLinkArgs) -> anyhow::Result<()> {
440 let root = require_workspace_root()?;
441 let target = CrossLinkTarget::parse(&args.to);
442 let warnings = workspace_config_edit::revoke_cross_link(&root, &args.from, &target)
443 .map_err(lift_edit_error)?;
444 let heading = format!(
445 "Workspace revoke-cross-link `{}` → `{}`",
446 args.from, args.to
447 );
448 let bullets = vec![
449 format!("From: `{}`", args.from),
450 format!("To: `{}`", args.to),
451 ];
452 confirm_block(
453 ctx,
454 "revoke-cross-link",
455 serde_json::json!({ "from": args.from, "to": args.to }),
456 &heading,
457 bullets,
458 &warnings,
459 )
460}
461
462fn show(ctx: &CliContext, _args: ShowArgs) -> anyhow::Result<()> {
463 use memstead_base::{FileWorkspaceStore, WorkspaceStoreAdapter};
464
465 let root = require_workspace_root()?;
466 let workspace = FileWorkspaceStore::new().load(&root).map_err(|e| {
467 CliError::new(
468 ExitKind::Generic,
469 "WORKSPACE_CONFIG_READ_FAILED",
470 format!("workspace show: load `{}`: {e}", root.display()),
471 )
472 })?;
473
474 let settings = &workspace.settings;
475 let json_mode = ctx.json;
476 if json_mode {
477 let create_rules: Vec<serde_json::Value> = settings
478 .mem_create_rules
479 .iter()
480 .map(|r| {
481 let mut obj = serde_json::Map::new();
482 obj.insert(
483 "pattern".to_string(),
484 serde_json::Value::String(r.pattern.clone()),
485 );
486 obj.insert(
487 "schemas".to_string(),
488 serde_json::Value::Array(
489 r.schemas
490 .iter()
491 .map(|s| serde_json::Value::String(s.clone()))
492 .collect(),
493 ),
494 );
495 if let Some(cl) = &r.default_cross_links {
496 obj.insert(
497 "default_cross_links".to_string(),
498 cross_link_value_to_json(cl),
499 );
500 }
501 serde_json::Value::Object(obj)
502 })
503 .collect();
504 let delete_rules: Vec<serde_json::Value> = settings
505 .mem_delete_rules
506 .iter()
507 .map(|r| serde_json::json!({ "pattern": r.pattern }))
508 .collect();
509 let mut cross_links_obj = serde_json::Map::new();
510 for (k, v) in &settings.cross_mem_links {
511 cross_links_obj.insert(k.clone(), cross_link_value_to_json(v));
512 }
513 let mut mutations_obj = serde_json::Map::new();
514 if let Some(rn) = settings.mutations.require_notes {
515 mutations_obj.insert("require_notes".to_string(), serde_json::Value::Bool(rn));
516 }
517 let mut plugin_obj = serde_json::Map::new();
518 for (k, v) in &settings.plugin {
519 plugin_obj.insert(k.clone(), serde_json::Value::String(v.to_string()));
520 }
521 let document = serde_json::json!({
522 "workspace_root": root.display().to_string(),
523 "mem_management": {
524 "create": create_rules,
525 "delete": delete_rules,
526 },
527 "cross_mem_links": cross_links_obj,
528 "mutations": mutations_obj,
529 "plugin": plugin_obj,
530 });
531 return print_json(&document);
532 }
533
534 let mut lines = Vec::new();
535 lines.push("# Workspace configuration".to_string());
536 lines.push(String::new());
537 lines.push(format!("- Root: `{}`", root.display()));
538 lines.push(String::new());
539
540 lines.push("## Mem management".to_string());
541 lines.push(String::new());
542 if settings.mem_create_rules.is_empty() {
543 lines.push(
544 "- `[[mem_management.create]]`: (none — no agent-driven mem creation allowed)"
545 .to_string(),
546 );
547 } else {
548 lines.push("- `[[mem_management.create]]`:".to_string());
549 for r in &settings.mem_create_rules {
550 let cross = match &r.default_cross_links {
551 None => String::new(),
552 Some(v) => format!(" → cross-links: {}", render_cross_link_value(v)),
553 };
554 lines.push(format!(
555 " - `{}` schemas=[{}]{cross}",
556 r.pattern,
557 r.schemas.join(", "),
558 ));
559 }
560 }
561 if settings.mem_delete_rules.is_empty() {
562 lines.push("- `[[mem_management.delete]]`: (none)".to_string());
563 } else {
564 lines.push("- `[[mem_management.delete]]`:".to_string());
565 for r in &settings.mem_delete_rules {
566 lines.push(format!(" - `{}`", r.pattern));
567 }
568 }
569 lines.push(String::new());
570
571 lines.push("## Cross-mem links".to_string());
572 lines.push(String::new());
573 if settings.cross_mem_links.is_empty() {
574 lines.push("- `[cross_mem_links]`: (none — default-deny)".to_string());
575 } else {
576 for (from, value) in &settings.cross_mem_links {
577 lines.push(format!("- `{from}` → {}", render_cross_link_value(value)));
578 }
579 }
580 lines.push(String::new());
581
582 lines.push("## Mutations".to_string());
583 lines.push(String::new());
584 match settings.mutations.require_notes {
585 Some(true) => lines.push("- `require_notes`: `true`".to_string()),
586 Some(false) => lines.push("- `require_notes`: `false`".to_string()),
587 None => lines.push("- `require_notes`: (unset — best-effort)".to_string()),
588 }
589 lines.push(String::new());
590
591 if !settings.plugin.is_empty() {
592 lines.push("## Plugin (opaque pass-through)".to_string());
593 lines.push(String::new());
594 let mut keys: Vec<&String> = settings.plugin.keys().collect();
595 keys.sort();
596 for k in keys {
597 lines.push(format!(
598 "- `[plugin.{k}]`: (operator-managed; CLI does not edit)"
599 ));
600 }
601 }
602
603 crate::output::print_markdown(&lines.join("\n"));
604 Ok(())
605}
606
607fn cross_link_value_to_json(
608 v: &memstead_schema::workspace_config::CrossLinkValue,
609) -> serde_json::Value {
610 use memstead_schema::workspace_config::CrossLinkValue;
611 match v {
612 CrossLinkValue::Wildcard => serde_json::Value::String("*".to_string()),
613 CrossLinkValue::List(names) => serde_json::Value::Array(
614 names
615 .iter()
616 .map(|n| serde_json::Value::String(n.clone()))
617 .collect(),
618 ),
619 }
620}
621
622fn render_cross_link_value(v: &memstead_schema::workspace_config::CrossLinkValue) -> String {
623 use memstead_schema::workspace_config::CrossLinkValue;
624 match v {
625 CrossLinkValue::Wildcard => "*".to_string(),
626 CrossLinkValue::List(names) => format!("[{}]", names.join(", ")),
627 }
628}
629
630fn set_mutations(ctx: &CliContext, args: SetMutationsArgs) -> anyhow::Result<()> {
631 let root = require_workspace_root()?;
632 if let Some(value) = args.require_notes {
633 workspace_config_edit::set_mutation_require_notes(&root, value).map_err(lift_edit_error)?;
634 let heading = "Workspace set-mutations".to_string();
635 let bullets = vec![format!("`require_notes`: `{value}`")];
636 confirm_block(
637 ctx,
638 "set-mutations",
639 serde_json::json!({ "require_notes": value }),
640 &heading,
641 bullets,
642 &[],
643 )
644 } else {
645 Err(CliError::new(
646 ExitKind::Validation,
647 "INVALID_INPUT",
648 "set-mutations requires at least one of: --require-notes <bool>",
649 )
650 .into())
651 }
652}
653
654const DUMP_FORMAT: &str = "workspace-dump/v0";
661
662#[derive(Serialize)]
663struct DumpMem {
664 name: String,
665 capability: &'static str,
669 #[serde(rename = "schema_ref")]
676 schema: Option<String>,
677 description: Option<String>,
679 write_guidance: Map<String, Value>,
687 #[serde(skip_serializing_if = "Option::is_none")]
695 snapshot_token: Option<String>,
696 #[serde(skip_serializing_if = "Map::is_empty")]
706 sync_state: Map<String, Value>,
707}
708
709#[derive(Serialize)]
710struct DumpSchema {
711 #[serde(rename = "default_writing_guidance")]
714 default_writing_guidance: SchemaWritingGuidance,
715}
716
717#[derive(Serialize, Default)]
718struct SchemaWritingGuidance {
719 avoid: Option<String>,
720 goal: Option<String>,
721}
722
723fn dump(_ctx: &CliContext, _args: DumpArgs) -> anyhow::Result<()> {
724 let setup_ctx = CliContext {
725 json: true,
726 quiet: false,
727 role: Default::default(),
728 };
729 let engine = crate::setup::full_engine(&setup_ctx).map_err(|e| {
734 CliError::new(
735 ExitKind::Generic,
736 "WORKSPACE_NOT_INITIALISED",
737 format!("workspace dump: could not initialize engine: {e}"),
738 )
739 })?;
740
741 let mut mems: Vec<DumpMem> = Vec::new();
742 let mut schemas: Map<String, Value> = Map::new();
743
744 for (name, config) in engine.mem_configs_named() {
745 let mount = engine.mount(name);
752 let capability = match mount.map(|m| m.capability) {
753 Some(memstead_base::MountCapability::ReadOnly) => "read_only",
754 _ => "writable",
755 };
756 let storage = mount.map(|m| &m.storage);
757 let snapshot_token: Option<String> = match storage {
758 Some(memstead_base::MountStorage::GitBranch { .. }) => {
759 let gitdir = engine.gitdir_for(name).map_err(|e| {
760 CliError::new(
761 ExitKind::Generic,
762 "MEM_ERROR",
763 format!("workspace dump: gitdir for mem '{name}': {e}"),
764 )
765 })?;
766 Some(read_branch_head_oid(&gitdir, name).map_err(|e| {
767 CliError::new(
768 ExitKind::Generic,
769 "MEM_ERROR",
770 format!("workspace dump: snapshot token for mem '{name}': {e}"),
771 )
772 })?)
773 }
774 _ => None,
779 };
780
781 let schema_pin = config
782 .schema
783 .as_ref()
784 .map(|p| {
785 serde_json::to_value(p)
786 .ok()
787 .and_then(|v| v.as_str().map(String::from))
788 })
789 .unwrap_or(None);
790
791 let mut write_guidance = Map::new();
792 for (k, v) in &config.write_guidance {
793 write_guidance.insert(k.clone(), v.clone());
794 }
795
796 let mut sync_state = Map::new();
800 for (k, v) in &config.sync_state {
801 sync_state.insert(k.clone(), Value::String(v.clone()));
802 }
803
804 mems.push(DumpMem {
805 name: name.to_string(),
806 capability,
807 schema: schema_pin.clone(),
808 description: config.description.clone(),
809 write_guidance,
810 snapshot_token,
811 sync_state,
812 });
813
814 if let Some(pin) = schema_pin
816 && !schemas.contains_key(&pin)
817 && let Some(schema) = engine.schema_for(name)
818 {
819 let dwg = schema
820 .manifest
821 .default_writing_guidance
822 .as_ref()
823 .map(|d| SchemaWritingGuidance {
824 avoid: d.avoid.clone(),
825 goal: d.goal.clone(),
826 })
827 .unwrap_or_default();
828 let body = DumpSchema {
829 default_writing_guidance: dwg,
830 };
831 schemas.insert(pin, serde_json::to_value(body)?);
832 }
833 }
834
835 mems.sort_by(|a, b| a.name.cmp(&b.name));
836
837 let workspace_root = std::env::current_dir()
838 .ok()
839 .and_then(|cwd| crate::setup::find_workspace_root(&cwd).map(|p| p.display().to_string()));
840
841 let document = serde_json::json!({
842 "format": DUMP_FORMAT,
843 "workspace_root": workspace_root,
844 "mems": mems,
845 "schemas": schemas,
846 });
847
848 print_json(&document)?;
849 Ok(())
850}
851
852fn read_branch_head_oid(gitdir: &Path, mem_name: &str) -> Result<String, String> {
861 if !gitdir.is_dir() {
862 return Err(format!("gitdir not found at {}", gitdir.display()));
863 }
864 let repo = gix::open(gitdir).map_err(|e| format!("gix open: {e}"))?;
865 let branch_ref = branch_ref_for_mem_at_gitdir(gitdir, mem_name);
866 let reference = repo
867 .find_reference(&branch_ref)
868 .map_err(|e| format!("find ref {branch_ref}: {e}"))?;
869 let oid = reference
870 .into_fully_peeled_id()
871 .map_err(|e| format!("peel {branch_ref}: {e}"))?;
872 Ok(oid.to_string())
873}