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
443 let before = ctx
447 .cli_engine()
448 .map(|mut e| e.base_mut().ungranted_cross_mem_edges())
449 .unwrap_or_default();
450
451 let mut warnings = workspace_config_edit::revoke_cross_link(&root, &args.from, &target)
452 .map_err(lift_edit_error)?;
453
454 let orphaned: Vec<String> = if warnings.is_empty() {
467 match ctx.cli_engine() {
468 Ok(mut engine) => memstead_base::Engine::newly_ungranted(
469 &before,
470 engine.base_mut().ungranted_cross_mem_edges(),
471 )
472 .iter()
473 .map(|f| {
474 let d = &f.detail;
475 format!(
476 "`{}` --{}-> `{}`",
477 d["from"].as_str().unwrap_or(&f.id),
478 d["rel_type"].as_str().unwrap_or("?"),
479 d["target_id"].as_str().unwrap_or("?"),
480 )
481 })
482 .collect(),
483 Err(_) => Vec::new(),
487 }
488 } else {
489 Vec::new()
490 };
491
492 let heading = format!(
493 "Workspace revoke-cross-link `{}` → `{}`",
494 args.from, args.to
495 );
496 let mut bullets = vec![
497 format!("From: `{}`", args.from),
498 format!("To: `{}`", args.to),
499 ];
500 if !orphaned.is_empty() {
501 warnings.push(
504 workspace_config_edit::WorkspaceEditWarning::CrossLinkRevokeOrphanedEdges {
505 edges: orphaned.clone(),
506 },
507 );
508 bullets.push(format!(
509 "Edges left without a grant ({}) — they are NOT removed; \
510 `memstead health --include integrity --strict` now refuses \
511 until each is granted again or removed:",
512 orphaned.len()
513 ));
514 for edge in &orphaned {
515 bullets.push(format!(" {edge}"));
516 }
517 }
518 confirm_block(
519 ctx,
520 "revoke-cross-link",
521 serde_json::json!({
522 "from": args.from,
523 "to": args.to,
524 "orphaned_edges": orphaned,
525 }),
526 &heading,
527 bullets,
528 &warnings,
529 )
530}
531
532fn show(ctx: &CliContext, _args: ShowArgs) -> anyhow::Result<()> {
533 use memstead_base::{FileWorkspaceStore, WorkspaceStoreAdapter};
534
535 let root = require_workspace_root()?;
536 let workspace = FileWorkspaceStore::new().load(&root).map_err(|e| {
537 CliError::new(
538 ExitKind::Generic,
539 "WORKSPACE_CONFIG_READ_FAILED",
540 format!("workspace show: load `{}`: {e}", root.display()),
541 )
542 })?;
543
544 let settings = &workspace.settings;
545 let json_mode = ctx.json;
546 if json_mode {
547 let create_rules: Vec<serde_json::Value> = settings
548 .mem_create_rules
549 .iter()
550 .map(|r| {
551 let mut obj = serde_json::Map::new();
552 obj.insert(
553 "pattern".to_string(),
554 serde_json::Value::String(r.pattern.clone()),
555 );
556 obj.insert(
557 "schemas".to_string(),
558 serde_json::Value::Array(
559 r.schemas
560 .iter()
561 .map(|s| serde_json::Value::String(s.clone()))
562 .collect(),
563 ),
564 );
565 if let Some(cl) = &r.default_cross_links {
566 obj.insert(
567 "default_cross_links".to_string(),
568 cross_link_value_to_json(cl),
569 );
570 }
571 serde_json::Value::Object(obj)
572 })
573 .collect();
574 let delete_rules: Vec<serde_json::Value> = settings
575 .mem_delete_rules
576 .iter()
577 .map(|r| serde_json::json!({ "pattern": r.pattern }))
578 .collect();
579 let mut cross_links_obj = serde_json::Map::new();
580 for (k, v) in &settings.cross_mem_links {
581 cross_links_obj.insert(k.clone(), cross_link_value_to_json(v));
582 }
583 let mut mutations_obj = serde_json::Map::new();
584 if let Some(rn) = settings.mutations.require_notes {
585 mutations_obj.insert("require_notes".to_string(), serde_json::Value::Bool(rn));
586 }
587 let mut plugin_obj = serde_json::Map::new();
588 for (k, v) in &settings.plugin {
589 plugin_obj.insert(k.clone(), serde_json::Value::String(v.to_string()));
590 }
591 let document = serde_json::json!({
592 "workspace_root": root.display().to_string(),
593 "mem_management": {
594 "create": create_rules,
595 "delete": delete_rules,
596 },
597 "cross_mem_links": cross_links_obj,
598 "mutations": mutations_obj,
599 "plugin": plugin_obj,
600 });
601 return print_json(&document);
602 }
603
604 let mut lines = Vec::new();
605 lines.push("# Workspace configuration".to_string());
606 lines.push(String::new());
607 lines.push(format!("- Root: `{}`", root.display()));
608 lines.push(String::new());
609
610 lines.push("## Mem management".to_string());
611 lines.push(String::new());
612 if settings.mem_create_rules.is_empty() {
613 lines.push(
614 "- `[[mem_management.create]]`: (none — no agent-driven mem creation allowed)"
615 .to_string(),
616 );
617 } else {
618 lines.push("- `[[mem_management.create]]`:".to_string());
619 for r in &settings.mem_create_rules {
620 let cross = match &r.default_cross_links {
621 None => String::new(),
622 Some(v) => format!(" → cross-links: {}", render_cross_link_value(v)),
623 };
624 lines.push(format!(
625 " - `{}` schemas=[{}]{cross}",
626 r.pattern,
627 r.schemas.join(", "),
628 ));
629 }
630 }
631 if settings.mem_delete_rules.is_empty() {
632 lines.push("- `[[mem_management.delete]]`: (none)".to_string());
633 } else {
634 lines.push("- `[[mem_management.delete]]`:".to_string());
635 for r in &settings.mem_delete_rules {
636 lines.push(format!(" - `{}`", r.pattern));
637 }
638 }
639 lines.push(String::new());
640
641 lines.push("## Cross-mem links".to_string());
642 lines.push(String::new());
643 if settings.cross_mem_links.is_empty() {
644 lines.push("- `[cross_mem_links]`: (none — default-deny)".to_string());
645 } else {
646 for (from, value) in &settings.cross_mem_links {
647 lines.push(format!("- `{from}` → {}", render_cross_link_value(value)));
648 }
649 }
650 lines.push(String::new());
651
652 lines.push("## Mutations".to_string());
653 lines.push(String::new());
654 match settings.mutations.require_notes {
655 Some(true) => lines.push("- `require_notes`: `true`".to_string()),
656 Some(false) => lines.push("- `require_notes`: `false`".to_string()),
657 None => lines.push("- `require_notes`: (unset — best-effort)".to_string()),
658 }
659 lines.push(String::new());
660
661 if !settings.plugin.is_empty() {
662 lines.push("## Plugin (opaque pass-through)".to_string());
663 lines.push(String::new());
664 let mut keys: Vec<&String> = settings.plugin.keys().collect();
665 keys.sort();
666 for k in keys {
667 lines.push(format!(
668 "- `[plugin.{k}]`: (operator-managed; CLI does not edit)"
669 ));
670 }
671 }
672
673 crate::output::print_markdown(&lines.join("\n"));
674 Ok(())
675}
676
677fn cross_link_value_to_json(
678 v: &memstead_schema::workspace_config::CrossLinkValue,
679) -> serde_json::Value {
680 use memstead_schema::workspace_config::CrossLinkValue;
681 match v {
682 CrossLinkValue::Wildcard => serde_json::Value::String("*".to_string()),
683 CrossLinkValue::List(names) => serde_json::Value::Array(
684 names
685 .iter()
686 .map(|n| serde_json::Value::String(n.clone()))
687 .collect(),
688 ),
689 }
690}
691
692fn render_cross_link_value(v: &memstead_schema::workspace_config::CrossLinkValue) -> String {
693 use memstead_schema::workspace_config::CrossLinkValue;
694 match v {
695 CrossLinkValue::Wildcard => "*".to_string(),
696 CrossLinkValue::List(names) => format!("[{}]", names.join(", ")),
697 }
698}
699
700fn set_mutations(ctx: &CliContext, args: SetMutationsArgs) -> anyhow::Result<()> {
701 let root = require_workspace_root()?;
702 if let Some(value) = args.require_notes {
703 workspace_config_edit::set_mutation_require_notes(&root, value).map_err(lift_edit_error)?;
704 let heading = "Workspace set-mutations".to_string();
705 let bullets = vec![format!("`require_notes`: `{value}`")];
706 confirm_block(
707 ctx,
708 "set-mutations",
709 serde_json::json!({ "require_notes": value }),
710 &heading,
711 bullets,
712 &[],
713 )
714 } else {
715 Err(CliError::new(
716 ExitKind::Validation,
717 "INVALID_INPUT",
718 "set-mutations requires at least one of: --require-notes <bool>",
719 )
720 .into())
721 }
722}
723
724const DUMP_FORMAT: &str = "workspace-dump/v1";
741
742#[derive(Serialize)]
749struct ServingState {
750 state: &'static str,
752 reason_code: String,
753 reason: String,
754}
755
756fn serving_state(engine: &memstead_base::Engine, name: &str) -> Option<ServingState> {
758 if let Some(q) = engine
759 .quarantined_mems()
760 .iter()
761 .find(|q| q.mount.mem == name)
762 {
763 return Some(ServingState {
764 state: "quarantined",
765 reason_code: q.reason_code.clone(),
766 reason: q.reason_message.clone(),
767 });
768 }
769 engine
770 .health()
771 .warnings
772 .iter()
773 .find(|w| {
774 matches!(
775 w,
776 memstead_base::ops::WarningHint::MountUnbacked { mem, .. } if mem == name
777 )
778 })
779 .map(|w| ServingState {
780 state: "unbacked",
781 reason_code: w.code().to_string(),
782 reason: w.to_string(),
783 })
784}
785
786#[derive(Serialize)]
787struct DumpMem {
788 name: String,
789 #[serde(skip_serializing_if = "Option::is_none")]
791 serving: Option<ServingState>,
792 capability: &'static str,
796 #[serde(rename = "schema_ref")]
803 schema: Option<String>,
804 description: Option<String>,
806 write_guidance: Map<String, Value>,
814 #[serde(skip_serializing_if = "Option::is_none")]
822 snapshot_token: Option<String>,
823 #[serde(skip_serializing_if = "Map::is_empty")]
833 sync_state: Map<String, Value>,
834}
835
836#[derive(Serialize)]
837struct DumpSchema {
838 #[serde(rename = "default_writing_guidance")]
841 default_writing_guidance: SchemaWritingGuidance,
842}
843
844#[derive(Serialize, Default)]
845struct SchemaWritingGuidance {
846 avoid: Option<String>,
847 goal: Option<String>,
848}
849
850fn dump(_ctx: &CliContext, _args: DumpArgs) -> anyhow::Result<()> {
851 let setup_ctx = CliContext {
852 json: true,
853 quiet: false,
854 role: Default::default(),
855 identity: None,
856 };
857 let engine =
866 crate::setup::full_engine(&setup_ctx).map_err(|e| match e.downcast::<CliError>() {
867 Ok(mut cli) => {
868 cli.message = format!("workspace dump: {}", cli.message);
869 anyhow::Error::from(cli)
870 }
871 Err(other) => other,
872 })?;
873
874 let mut mems: Vec<DumpMem> = Vec::new();
875 let mut schemas: Map<String, Value> = Map::new();
876
877 for (name, config) in engine.mounts_with_optional_config() {
883 let mount = engine.mount(name);
890 let capability = match mount.map(|m| m.capability) {
891 Some(memstead_base::MountCapability::ReadOnly) => "read_only",
892 _ => "writable",
893 };
894 let storage = mount.map(|m| &m.storage);
895 let snapshot_token: Option<String> = match storage {
896 Some(memstead_base::MountStorage::GitBranch { .. }) => {
897 let gitdir = engine.gitdir_for(name).map_err(|e| {
898 CliError::new(
899 ExitKind::Generic,
900 "MEM_ERROR",
901 format!("workspace dump: gitdir for mem '{name}': {e}"),
902 )
903 })?;
904 Some(read_branch_head_oid(&gitdir, name).map_err(|e| {
905 CliError::new(
906 ExitKind::Generic,
907 "MEM_ERROR",
908 format!("workspace dump: snapshot token for mem '{name}': {e}"),
909 )
910 })?)
911 }
912 _ => None,
917 };
918
919 let schema_pin = config
920 .and_then(|c| c.schema.as_ref())
921 .map(|p| {
922 serde_json::to_value(p)
923 .ok()
924 .and_then(|v| v.as_str().map(String::from))
925 })
926 .unwrap_or(None);
927
928 let mut write_guidance = Map::new();
929 for (k, v) in config.iter().flat_map(|c| c.write_guidance.iter()) {
930 write_guidance.insert(k.clone(), v.clone());
931 }
932
933 let mut sync_state = Map::new();
937 for (k, v) in config.iter().flat_map(|c| c.sync_state.iter()) {
938 sync_state.insert(k.clone(), Value::String(v.clone()));
939 }
940
941 mems.push(DumpMem {
942 name: name.to_string(),
943 capability,
944 schema: schema_pin.clone(),
945 description: config.and_then(|c| c.description.clone()),
946 serving: serving_state(&engine, name),
947 write_guidance,
948 snapshot_token,
949 sync_state,
950 });
951
952 if let Some(pin) = schema_pin
954 && !schemas.contains_key(&pin)
955 && let Some(schema) = engine.schema_for(name)
956 {
957 let dwg = schema
958 .manifest
959 .default_writing_guidance
960 .as_ref()
961 .map(|d| SchemaWritingGuidance {
962 avoid: d.avoid.clone(),
963 goal: d.goal.clone(),
964 })
965 .unwrap_or_default();
966 let body = DumpSchema {
967 default_writing_guidance: dwg,
968 };
969 schemas.insert(pin, serde_json::to_value(body)?);
970 }
971 }
972
973 mems.sort_by(|a, b| a.name.cmp(&b.name));
974
975 let workspace_root = std::env::current_dir()
976 .ok()
977 .and_then(|cwd| crate::setup::find_workspace_root(&cwd).map(|p| p.display().to_string()));
978
979 let document = serde_json::json!({
980 "format": DUMP_FORMAT,
981 "verdict_coverage": crate::coverage::WORKSPACE_DUMP
985 .axis_coverage()
986 .expect("workspace dump is a verdict surface")
987 .wire_line(),
988 "workspace_root": workspace_root,
989 "mems": mems,
990 "schemas": schemas,
991 });
992
993 print_json(&document)?;
994 Ok(())
995}
996
997fn read_branch_head_oid(gitdir: &Path, mem_name: &str) -> Result<String, String> {
1006 if !gitdir.is_dir() {
1007 return Err(format!("gitdir not found at {}", gitdir.display()));
1008 }
1009 let repo = gix::open(gitdir).map_err(|e| format!("gix open: {e}"))?;
1010 let branch_ref = branch_ref_for_mem_at_gitdir(gitdir, mem_name);
1011 let reference = repo
1012 .find_reference(&branch_ref)
1013 .map_err(|e| format!("find ref {branch_ref}: {e}"))?;
1014 let oid = reference
1015 .into_fully_peeled_id()
1016 .map_err(|e| format!("peel {branch_ref}: {e}"))?;
1017 Ok(oid.to_string())
1018}