use std::path::{Path, PathBuf};
#[cfg(feature = "mem-repo")]
use anyhow::Context;
use memstead_base::Engine as BaseEngine;
use memstead_base::vcs::ClientId;
#[cfg(feature = "mem-repo")]
use memstead_base::vcs::{Actor, CommitContext};
#[cfg(feature = "mem-repo")]
use memstead_git_branch::workspace_store::engine_from_workspace_root;
use crate::CliError;
use crate::output::ExitKind;
pub const WORKSPACE_NOT_INITIALISED_CODE: &str = "WORKSPACE_NOT_INITIALISED";
#[cfg(feature = "mem-repo")]
pub const WORKSPACE_RECOVERY_COMMAND: &str = "memstead mem-repo init";
#[cfg(not(feature = "mem-repo"))]
pub const WORKSPACE_RECOVERY_COMMAND: &str = "memstead init";
pub fn workspace_not_initialised_error(message: &str) -> CliError {
CliError {
kind: ExitKind::Generic,
code: WORKSPACE_NOT_INITIALISED_CODE,
message: message.to_string(),
details: Some(serde_json::json!({
"hint": { "recovery_command": WORKSPACE_RECOVERY_COMMAND },
})),
}
}
pub fn boot_error_to_cli(workspace_root: &Path, e: memstead_base::BootError) -> CliError {
let details = e.details();
let details = match &details {
serde_json::Value::Object(map) if map.is_empty() => None,
_ => Some(details),
};
CliError {
kind: ExitKind::Generic,
code: e.code(),
message: e.surface_message(workspace_root),
details,
}
}
pub struct CliContext {
pub json: bool,
pub quiet: bool,
pub role: memstead_base::vcs::Role,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceShape {
MemRepo,
Filesystem,
}
pub fn shell_quote(value: &str) -> String {
let safe = |c: char| c.is_ascii_alphanumeric() || "._-/@:+,=".contains(c);
if !value.is_empty() && !value.starts_with('-') && value.chars().all(safe) {
return value.to_string();
}
format!("'{}'", value.replace('\'', r"'\''"))
}
fn memstead_word() -> String {
shell_quote(&memstead_program())
}
#[cfg(feature = "mem-repo")]
fn unsupported_workspace_shape_message() -> String {
let m = memstead_word();
format!(
"this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — \
bootstrap one with `{m} mem-repo init` in a fresh folder, or use `{m} status` / \
`{m} list` / `{m} search` / `{m} entity` / `{m} health` / \
`{m} create|update|delete|relate|rename` here instead."
)
}
pub fn memstead_program() -> String {
let Ok(exe) = std::env::current_exe() else {
return "memstead".to_string();
};
let canonical_exe = exe.canonicalize().unwrap_or_else(|_| exe.clone());
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join("memstead");
if candidate.is_file() && candidate.canonicalize().is_ok_and(|c| c == canonical_exe) {
return "memstead".to_string();
}
}
}
exe.display().to_string()
}
#[cfg(feature = "mem-repo")]
fn mem_repo_init_hint() -> String {
format!("`{} mem-repo init` in a fresh folder", memstead_word())
}
#[cfg(not(feature = "mem-repo"))]
fn mem_repo_init_hint() -> String {
"the full build of memstead (this lean build has no `mem-repo` subcommand), then \
`memstead mem-repo init` in a fresh folder"
.to_string()
}
#[cfg(feature = "mem-repo")]
const FILESYSTEM_CANNOT: &str = "**It cannot install mems from the registry.** `memstead install \
<scope>/<name>` (and the other mem-repo-only subcommands) refuse here with \
`UNSUPPORTED_WORKSPACE_SHAPE`.";
#[cfg(not(feature = "mem-repo"))]
const FILESYSTEM_CANNOT: &str = "**It cannot install mems from the registry, and holds exactly \
one mem.** The subcommands that do either are mem-repo-only, and this lean build does not \
carry them at all.";
impl WorkspaceShape {
pub fn at(workspace_root: &Path) -> Self {
if memstead_base::is_mem_repo_shaped(workspace_root) {
WorkspaceShape::MemRepo
} else {
WorkspaceShape::Filesystem
}
}
pub fn label(self) -> &'static str {
match self {
WorkspaceShape::MemRepo => "mem-repo",
WorkspaceShape::Filesystem => "filesystem-mem",
}
}
}
pub struct ShapeDisclosure {
pub shape: WorkspaceShape,
pub summary: &'static str,
pub cannot: &'static str,
pub other_shape: WorkspaceShape,
pub other_shape_command: String,
}
pub fn shape_disclosure(shape: WorkspaceShape) -> ShapeDisclosure {
match shape {
WorkspaceShape::Filesystem => ShapeDisclosure {
shape,
summary: "One mem, plain `.md` files in this folder, no git history — nothing \
else to set up.",
cannot: FILESYSTEM_CANNOT,
other_shape: WorkspaceShape::MemRepo,
other_shape_command: format!(
"**The other shape** — mem-repo: many mems, git-backed, registry-capable — \
comes from {hint}. Switching later means starting a second \
workspace, so decide now if you intend to install mems.",
hint = mem_repo_init_hint(),
),
},
WorkspaceShape::MemRepo => ShapeDisclosure {
shape,
summary: "Many mems on git branches, full history — every subcommand works here, \
including `memstead install <scope>/<name>`.",
cannot: "**It costs a git repository.** The mems live in `mem-repo/.git/` and \
every mutation is a commit — not a folder of files you can hand-edit.",
other_shape: WorkspaceShape::Filesystem,
other_shape_command: format!(
"**The other shape** — filesystem-mem: one mem, plain `.md` files, no git — \
comes from `{} quickstart` in a fresh folder.",
memstead_word(),
),
},
}
}
impl ShapeDisclosure {
pub fn lines(&self) -> Vec<String> {
vec![
format!("## Workspace shape: {}", self.shape.label()),
String::new(),
self.summary.to_string(),
String::new(),
format!("- {}", self.cannot),
format!("- {}", self.other_shape_command),
]
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"shape": self.shape.label(),
"summary": self.summary,
"cannot": self.cannot,
"other_shape": self.other_shape.label(),
"other_shape_command": self.other_shape_command,
})
}
}
pub fn shape_disclosure_lines(shape: WorkspaceShape) -> Vec<String> {
shape_disclosure(shape).lines()
}
pub enum CliEngine {
#[cfg(feature = "mem-repo")]
MemRepo(BaseEngine),
Filesystem(BaseEngine),
}
impl CliEngine {
pub fn base(&self) -> &BaseEngine {
#[cfg(feature = "mem-repo")]
{
match self {
CliEngine::MemRepo(e) => e,
CliEngine::Filesystem(e) => e,
}
}
#[cfg(not(feature = "mem-repo"))]
{
let CliEngine::Filesystem(e) = self;
e
}
}
pub fn base_mut(&mut self) -> &mut BaseEngine {
#[cfg(feature = "mem-repo")]
{
match self {
CliEngine::MemRepo(e) => e,
CliEngine::Filesystem(e) => e,
}
}
#[cfg(not(feature = "mem-repo"))]
{
let CliEngine::Filesystem(e) = self;
e
}
}
pub fn into_base(self) -> BaseEngine {
#[cfg(feature = "mem-repo")]
{
match self {
CliEngine::MemRepo(e) => e,
CliEngine::Filesystem(e) => e,
}
}
#[cfg(not(feature = "mem-repo"))]
{
let CliEngine::Filesystem(e) = self;
e
}
}
}
impl CliContext {
pub fn workspace_shape(&self) -> Option<(WorkspaceShape, PathBuf)> {
let cwd = std::env::current_dir().ok()?;
let root = find_workspace_root(&cwd)?;
Some((WorkspaceShape::at(&root), root))
}
pub fn cli_engine(&self) -> anyhow::Result<CliEngine> {
match self.workspace_shape() {
Some((_, root)) => self.cli_engine_at(&root),
None => Err(workspace_not_initialised_error(
"No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead init` for a folder-mount workspace, or `memstead mem-repo init` for a mem-repo workspace).",
)
.into()),
}
}
pub fn cli_engine_at(&self, root: &Path) -> anyhow::Result<CliEngine> {
if memstead_base::is_mem_repo_shaped(root) {
#[cfg(feature = "mem-repo")]
{
let mut engine =
engine_from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
engine.set_role(self.role);
return Ok(CliEngine::MemRepo(engine));
}
#[cfg(not(feature = "mem-repo"))]
{
return Err(CliError {
kind: ExitKind::Generic,
code: "UNSUPPORTED_WORKSPACE_SHAPE",
message:
"this is the lean build of memstead (folder-mount only); the workspace is mem-repo-shaped (`mem-repo/.git/` present). Install the full build (`cargo build --features mem-repo`) or run from a workspace whose mounts are all folder-backed."
.to_string(),
details: None,
}
.into());
}
}
let mut engine =
BaseEngine::from_workspace_root(root).map_err(|e| boot_error_to_cli(root, e))?;
engine.set_role(self.role);
Ok(CliEngine::Filesystem(engine))
}
#[cfg(feature = "mem-repo")]
pub fn engine(&self) -> anyhow::Result<BaseEngine> {
let cwd = std::env::current_dir().context("Could not determine current directory")?;
let Some(root) = find_workspace_root(&cwd) else {
return Err(workspace_not_initialised_error(
"No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
)
.into());
};
if !memstead_base::is_mem_repo_shaped(&root) {
return Err(CliError {
kind: ExitKind::Generic,
code: "UNSUPPORTED_WORKSPACE_SHAPE",
message: unsupported_workspace_shape_message(),
details: None,
}
.into());
}
let mut engine =
engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
engine.set_role(self.role);
Ok(engine)
}
}
pub fn find_workspace_root(start: &Path) -> Option<PathBuf> {
let mut cursor: PathBuf = if start.is_dir() {
start.to_path_buf()
} else {
start.parent()?.to_path_buf()
};
loop {
if memstead_base::is_workspace_root(&cursor) {
return Some(cursor);
}
let parent = cursor.parent()?;
if parent == cursor {
return None;
}
cursor = parent.to_path_buf();
}
}
pub fn find_filesystem_workspace_root(start: &Path) -> Option<PathBuf> {
find_workspace_root(start)
}
#[cfg(feature = "mem-repo")]
pub fn cli_ctx() -> CommitContext<'static> {
cli_ctx_with_note(None)
}
pub fn cli_client_id() -> ClientId {
ClientId {
name: "memstead-cli".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
#[cfg(feature = "mem-repo")]
pub fn cli_ctx_with_note(note: Option<String>) -> CommitContext<'static> {
CommitContext {
actor: Actor::Cli,
client: Some(cli_client_id()),
tool: None,
note,
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
}
}
#[cfg(feature = "mem-repo")]
pub fn full_engine(_ctx: &CliContext) -> anyhow::Result<BaseEngine> {
let cwd = std::env::current_dir().map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("could not determine the current directory ({e}) — run from a directory that exists and is readable"),
)
})?;
let Some(root) = find_workspace_root(&cwd) else {
return Err(workspace_not_initialised_error(
"No workspace found. Run from a directory containing `.memstead/workspace.toml` (run `memstead mem-repo init` to bootstrap).",
)
.into());
};
if !memstead_base::is_mem_repo_shaped(&root) {
return Err(CliError {
code: "UNSUPPORTED_WORKSPACE_SHAPE",
kind: ExitKind::Generic,
message: unsupported_workspace_shape_message(),
details: None,
}
.into());
}
let mut engine = engine_from_workspace_root(&root).map_err(|e| boot_error_to_cli(&root, e))?;
engine.set_role(_ctx.role);
Ok(engine)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch_marker(ws: &std::path::Path) {
std::fs::create_dir_all(ws.join(".memstead")).unwrap();
std::fs::write(ws.join(".memstead").join("workspace.toml"), "").unwrap();
}
#[test]
fn find_workspace_root_walks_up_to_marker() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
let nested = ws.join("a").join("b").join("specs");
std::fs::create_dir_all(&nested).unwrap();
touch_marker(&ws);
let found =
find_workspace_root(&nested).expect("walk should find .memstead/workspace.toml");
assert_eq!(found.canonicalize().unwrap(), ws.canonicalize().unwrap());
}
#[test]
fn find_workspace_root_returns_none_when_absent() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
assert!(find_workspace_root(&nested).is_none());
}
#[test]
fn find_workspace_root_stops_at_containing_dir() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
touch_marker(&ws);
let found = find_workspace_root(&ws).expect("ws itself carries .memstead/workspace.toml");
assert_eq!(found, ws);
}
#[test]
fn find_workspace_root_accepts_file_start() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
touch_marker(&ws);
let file = ws.join("some-file.md");
std::fs::write(&file, "").unwrap();
let found = find_workspace_root(&file).expect("file start should resolve to its dir");
assert_eq!(found, ws);
}
#[test]
fn find_workspace_root_deeper_marker_wins() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let inner = outer.join("inner");
let deep = inner.join("a").join("b");
std::fs::create_dir_all(&deep).unwrap();
touch_marker(&outer);
touch_marker(&inner);
let found = find_workspace_root(&deep).expect("walk should find the inner marker");
assert_eq!(found.canonicalize().unwrap(), inner.canonicalize().unwrap());
}
}