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 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)?;
let shape = if root.join("mem-repo").join(".git").is_dir() {
WorkspaceShape::MemRepo
} else {
WorkspaceShape::Filesystem
};
Some((shape, 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 root.join("mem-repo").join(".git").is_dir() {
#[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 !root.join("mem-repo").join(".git").is_dir() {
return Err(CliError {
kind: ExitKind::Generic,
code: "UNSUPPORTED_WORKSPACE_SHAPE",
message:
"this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — run from a mem-repo workspace, or use `memstead status` / `memstead list` / `memstead search` / `memstead entity` / `memstead health` / `memstead create|update|delete|relate|rename` instead."
.to_string(),
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 !root.join("mem-repo").join(".git").is_dir() {
return Err(CliError {
code: "UNSUPPORTED_WORKSPACE_SHAPE",
kind: ExitKind::Generic,
message:
"this subcommand is mem-repo-only and not yet supported on filesystem-mem workspaces — run from a mem-repo workspace, or use `memstead status` / `memstead list` / `memstead search` / `memstead entity` / `memstead health` / `memstead create|update|delete|relate|rename` instead."
.to_string(),
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());
}
}