use std::path::{Path, PathBuf};
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 struct CliContext {
pub json: bool,
pub quiet: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceShape {
MemRepo,
Filesystem,
}
pub enum CliEngine {
#[cfg(feature = "mem-repo")]
MemRepo(BaseEngine),
Filesystem(BaseEngine),
}
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 engine = engine_from_workspace_root(root)
.map_err(|e| anyhow::anyhow!("init engine at {}: {e:#}", root.display()))?;
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 engine = BaseEngine::from_workspace_root(root)
.with_context(|| format!("init filesystem-mem engine at {}", root.display()))?;
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());
}
engine_from_workspace_root(&root)
.map_err(|e| anyhow::anyhow!("init engine at {}: {e:#}", root.display()))
}
}
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,
logical_operation_id: None,
entity_ids: None,
}
}
#[cfg(feature = "mem-repo")]
pub fn pro_engine(_ctx: &CliContext) -> anyhow::Result<BaseEngine> {
let cwd = std::env::current_dir().map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("could not determine current directory: {e}"),
)
})?;
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());
}
engine_from_workspace_root(&root)
.map_err(|e| anyhow::anyhow!("init engine at {}: {e:#}", root.display()))
}
#[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());
}
}