use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Result, ShoreError};
use crate::git::{git_common_dir, git_worktree_root};
const STORE_CONFIG_SCHEMA: &str = "shore.store-config";
const STORE_CONFIG_VERSION: u32 = 1;
const STORE_LINK_SCHEMA: &str = "shore.store-link";
const STORE_LINK_VERSION: u32 = 1;
const STORE_LINK_FILE: &str = "shore.link.json";
pub(crate) const STORE_CONFIG_REL_PATH: &str = ".shore/store.json";
pub(crate) const STORE_CONFIG_LOCAL_REL_PATH: &str = ".shore/store.local.json";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StoreMode {
#[default]
Shared,
Ephemeral,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StoreConfig {
schema: String,
version: u32,
mode: StoreMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
family_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
clone_ref: Option<String>,
}
impl StoreConfig {
fn new(mode: StoreMode) -> Self {
Self {
schema: STORE_CONFIG_SCHEMA.to_owned(),
version: STORE_CONFIG_VERSION,
mode,
family_ref: None,
clone_ref: None,
}
}
fn validate_schema_version(&self, path: &Path) -> Result<()> {
if self.schema == STORE_CONFIG_SCHEMA && self.version == STORE_CONFIG_VERSION {
return Ok(());
}
Err(ShoreError::Message(format!(
"store config {} has unsupported schema/version {} v{} (expected {} v{}); \
rewrite it with `shore store mode shared` or `shore store mode ephemeral`",
path.display(),
self.schema,
self.version,
STORE_CONFIG_SCHEMA,
STORE_CONFIG_VERSION
)))
}
}
pub(crate) fn resolve_store_mode(worktree_root: &Path) -> Result<StoreMode> {
let committed = load_store_config(&worktree_root.join(STORE_CONFIG_REL_PATH))?;
let local = load_store_config(&worktree_root.join(STORE_CONFIG_LOCAL_REL_PATH))?;
Ok(local
.or(committed)
.map(|config| config.mode)
.unwrap_or_default())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct FamilyBinding {
pub family_ref: String,
pub clone_ref: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommonDirBinding {
schema: String,
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
family_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
clone_ref: Option<String>,
}
pub(crate) fn common_dir_binding_path(common_dir: &Path) -> PathBuf {
common_dir.join(STORE_LINK_FILE)
}
pub(crate) fn read_common_dir_binding(common_dir: &Path) -> Result<Option<FamilyBinding>> {
let path = common_dir_binding_path(common_dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(ShoreError::Message(format!(
"read common-dir store binding {}: {error}",
path.display()
)));
}
};
let doc: CommonDirBinding = serde_json::from_slice(&bytes).map_err(|error| {
ShoreError::Message(format!(
"common-dir store binding {} is malformed: {error}",
path.display()
))
})?;
if doc.schema != STORE_LINK_SCHEMA || doc.version != STORE_LINK_VERSION {
return Err(ShoreError::Message(format!(
"common-dir store binding {} has unsupported schema/version {} v{} (expected {} v{}); \
re-run `shore store link <slug>` to rewrite it, or `shore store unlink` to clear it",
path.display(),
doc.schema,
doc.version,
STORE_LINK_SCHEMA,
STORE_LINK_VERSION
)));
}
match (doc.family_ref, doc.clone_ref) {
(Some(family_ref), Some(clone_ref)) => Ok(Some(FamilyBinding {
family_ref,
clone_ref,
})),
(None, None) => Ok(None),
_ => Err(ShoreError::Message(format!(
"common-dir store binding {} carries only one of familyRef/cloneRef; a family binding \
needs both. Re-run `shore store link <slug>`, or `shore store unlink` to clear it",
path.display(),
))),
}
}
pub(crate) fn write_common_dir_binding(
common_dir: &Path,
family_ref: &str,
clone_ref: &str,
) -> Result<()> {
let doc = CommonDirBinding {
schema: STORE_LINK_SCHEMA.to_owned(),
version: STORE_LINK_VERSION,
family_ref: Some(family_ref.to_owned()),
clone_ref: Some(clone_ref.to_owned()),
};
let mut bytes = serde_json::to_vec_pretty(&doc)?;
bytes.push(b'\n');
let path = common_dir_binding_path(common_dir);
std::fs::write(&path, &bytes)
.map_err(|error| ShoreError::Message(format!("write {}: {error}", path.display())))
}
pub(crate) fn remove_common_dir_binding(common_dir: &Path) -> Result<()> {
let path = common_dir_binding_path(common_dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ShoreError::Message(format!(
"remove {}: {error}",
path.display()
))),
}
}
pub(crate) fn resolve_family_binding(worktree_root: &Path) -> Result<Option<FamilyBinding>> {
let committed_path = worktree_root.join(STORE_CONFIG_REL_PATH);
if let Some(committed) = load_store_config(&committed_path)?
&& (committed.family_ref.is_some() || committed.clone_ref.is_some())
{
return Err(ShoreError::Message(format!(
"committed store config {} carries a family binding (familyRef/cloneRef), but the \
user-level family tier is opt-in per clone and must never be committed. Remove those \
fields from {STORE_CONFIG_REL_PATH} and run `shore store link <slug>` locally instead.",
committed_path.display(),
)));
}
let common_dir = git_common_dir(worktree_root)?;
if let Some(binding) = read_common_dir_binding(&common_dir)? {
return Ok(Some(binding));
}
let local_path = worktree_root.join(STORE_CONFIG_LOCAL_REL_PATH);
let Some(local) = load_store_config(&local_path)? else {
return Ok(None);
};
match (local.family_ref, local.clone_ref) {
(Some(family_ref), Some(clone_ref)) => Ok(Some(FamilyBinding {
family_ref,
clone_ref,
})),
(None, None) => Ok(None),
_ => Err(ShoreError::Message(format!(
"local store config {} carries only one of familyRef/cloneRef; a family binding needs \
both. Re-run `shore store link <slug>` to rewrite it, or `shore store unlink` to \
clear it.",
local_path.display(),
))),
}
}
fn load_store_config(path: &Path) -> Result<Option<StoreConfig>> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(ShoreError::Message(format!(
"read store config {}: {error}",
path.display()
)));
}
};
let config: StoreConfig = serde_json::from_slice(&bytes).map_err(|error| {
ShoreError::Message(format!(
"store config {} is malformed: {error}; \
expected a JSON document with a \"mode\" of \"shared\" or \"ephemeral\" \
(e.g. run `shore store mode shared` to rewrite it)",
path.display()
))
})?;
config.validate_schema_version(path)?;
Ok(Some(config))
}
pub(crate) fn write_store_config(worktree_root: &Path, mode: StoreMode) -> Result<()> {
write_store_config_document(
&worktree_root.join(STORE_CONFIG_REL_PATH),
&StoreConfig::new(mode),
)
}
fn write_store_config_document(path: &Path, config: &StoreConfig) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
ShoreError::Message(format!("create {}: {error}", parent.display()))
})?;
}
let mut bytes = serde_json::to_vec_pretty(config)?;
bytes.push(b'\n');
std::fs::write(path, &bytes)
.map_err(|error| ShoreError::Message(format!("write {}: {error}", path.display())))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum StoreModeSource {
Default,
Committed,
Local,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StoreModeOutcome {
pub mode: StoreMode,
pub source: StoreModeSource,
}
pub fn resolve_store_mode_for_repo(repo: &Path) -> Result<StoreModeOutcome> {
let worktree_root = git_worktree_root(repo)?;
let mode = resolve_store_mode(&worktree_root)?;
let source = if worktree_root.join(STORE_CONFIG_LOCAL_REL_PATH).exists() {
StoreModeSource::Local
} else if worktree_root.join(STORE_CONFIG_REL_PATH).exists() {
StoreModeSource::Committed
} else {
StoreModeSource::Default
};
Ok(StoreModeOutcome { mode, source })
}
pub fn set_store_mode_for_repo(repo: &Path, mode: StoreMode) -> Result<()> {
let worktree_root = git_worktree_root(repo)?;
if mode == StoreMode::Ephemeral {
crate::session::store::store_init::ensure_shore_gitignore(&worktree_root)?;
}
write_store_config(&worktree_root, mode)
}
pub(crate) fn set_family_binding_for_repo(repo: &Path, slug: &str, clone_ref: &str) -> Result<()> {
crate::session::store::user_level::validate_family_slug(slug)?;
let common_dir = git_common_dir(repo)?;
write_common_dir_binding(&common_dir, slug, clone_ref)?;
let worktree_root = git_worktree_root(repo)?;
if resolve_store_mode(&worktree_root)? == StoreMode::Ephemeral {
crate::session::store::store_init::ensure_shore_gitignore(&worktree_root)?;
write_store_config_document(
&worktree_root.join(STORE_CONFIG_LOCAL_REL_PATH),
&StoreConfig::new(StoreMode::default()),
)?;
}
Ok(())
}
pub(crate) fn clear_family_binding_for_repo(repo: &Path) -> Result<()> {
let common_dir = git_common_dir(repo)?;
remove_common_dir_binding(&common_dir)?;
let worktree_root = git_worktree_root(repo)?;
let local_path = worktree_root.join(STORE_CONFIG_LOCAL_REL_PATH);
let Some(existing) = load_store_config(&local_path)? else {
return Ok(());
};
if existing.mode == StoreMode::default() {
std::fs::remove_file(&local_path).map_err(|error| {
ShoreError::Message(format!("remove {}: {error}", local_path.display()))
})?;
return Ok(());
}
write_store_config_document(&local_path, &StoreConfig::new(existing.mode))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &std::path::Path, rel: &str, contents: &str) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git_repo() -> tempfile::TempDir {
let repo = tempfile::tempdir().expect("create temp git repository directory");
let output = std::process::Command::new("git")
.arg("init")
.current_dir(repo.path())
.output()
.expect("run git init");
assert!(output.status.success(), "git init failed");
repo
}
#[test]
fn store_mode_defaults_to_shared() {
assert_eq!(StoreMode::default(), StoreMode::Shared);
}
#[test]
fn absent_both_files_resolves_to_shared() {
let root = tempfile::tempdir().unwrap();
assert_eq!(resolve_store_mode(root.path()).unwrap(), StoreMode::Shared);
}
#[test]
fn committed_config_round_trips_through_the_reader() {
let root = tempfile::tempdir().unwrap();
write_store_config(root.path(), StoreMode::Ephemeral).unwrap();
assert!(root.path().join(".shore/store.json").is_file());
assert_eq!(
resolve_store_mode(root.path()).unwrap(),
StoreMode::Ephemeral
);
}
#[test]
fn camel_case_mode_strings_are_used_on_the_wire() {
let root = tempfile::tempdir().unwrap();
write_store_config(root.path(), StoreMode::Ephemeral).unwrap();
let raw = std::fs::read_to_string(root.path().join(".shore/store.json")).unwrap();
assert!(raw.contains("\"mode\": \"ephemeral\""), "got: {raw}");
assert!(
raw.contains("\"schema\": \"shore.store-config\""),
"got: {raw}"
);
}
#[test]
fn local_override_wins_over_committed() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".shore/store.json", SHARED_DOC);
write(root.path(), ".shore/store.local.json", EPHEMERAL_DOC);
assert_eq!(
resolve_store_mode(root.path()).unwrap(),
StoreMode::Ephemeral
);
}
#[test]
fn local_alone_is_used_when_committed_absent() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".shore/store.local.json", EPHEMERAL_DOC);
assert_eq!(
resolve_store_mode(root.path()).unwrap(),
StoreMode::Ephemeral
);
}
#[test]
fn committed_alone_is_used_when_local_absent() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".shore/store.json", EPHEMERAL_DOC);
assert_eq!(
resolve_store_mode(root.path()).unwrap(),
StoreMode::Ephemeral
);
}
#[test]
fn unsupported_schema_version_is_rejected_with_actionable_message() {
let root = tempfile::tempdir().unwrap();
write(
root.path(),
".shore/store.local.json",
r#"{"schema":"shore.store-config","version":999,"mode":"shared"}"#,
);
let err = resolve_store_mode(root.path()).unwrap_err().to_string();
assert!(err.contains("store mode"), "names the fix command: {err}");
assert!(
err.contains("store.local.json"),
"names the offending file: {err}"
);
}
#[test]
fn malformed_config_is_rejected_with_actionable_message() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".shore/store.json", "{ not json");
let err = resolve_store_mode(root.path()).unwrap_err().to_string();
assert!(err.contains("store.json"), "names the file: {err}");
assert!(
err.contains("shared") && err.contains("ephemeral"),
"names the valid modes: {err}"
);
}
const LOCAL_BINDING_DOC: &str = r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#;
#[test]
fn a_full_local_binding_resolves() {
let root = git_repo();
write(root.path(), ".shore/store.local.json", LOCAL_BINDING_DOC);
let binding = resolve_family_binding(root.path())
.unwrap()
.expect("a full binding resolves");
assert_eq!(binding.family_ref, "acme-web");
assert_eq!(binding.clone_ref, "0123abcd4567ef89");
}
#[test]
fn a_committed_binding_is_a_hard_error() {
let root = tempfile::tempdir().unwrap();
write(root.path(), ".shore/store.json", LOCAL_BINDING_DOC);
let err = resolve_family_binding(root.path())
.expect_err("a committed binding is rejected")
.to_string();
assert!(
err.contains("store.json"),
"names the committed file: {err}"
);
assert!(
err.contains("store link") || err.contains("opt-in"),
"explains the local-only opt-in: {err}"
);
}
#[test]
fn a_half_binding_in_the_local_file_is_a_hard_error() {
let root = git_repo();
write(
root.path(),
".shore/store.local.json",
r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"acme-web"}"#,
);
let err = resolve_family_binding(root.path())
.expect_err("familyRef without cloneRef is rejected")
.to_string();
assert!(
err.contains("store.local.json"),
"names the local file: {err}"
);
assert!(
err.contains("both") || err.contains("cloneRef"),
"explains both fields are required: {err}"
);
}
#[test]
fn no_binding_resolves_to_none() {
let root = git_repo();
assert!(resolve_family_binding(root.path()).unwrap().is_none());
write(root.path(), ".shore/store.local.json", EPHEMERAL_DOC);
assert!(resolve_family_binding(root.path()).unwrap().is_none());
}
#[test]
fn a_mode_only_local_file_still_resolves_its_mode_with_a_binding_present_field_absent() {
let root = git_repo();
write(root.path(), ".shore/store.local.json", EPHEMERAL_DOC);
assert_eq!(
resolve_store_mode(root.path()).unwrap(),
StoreMode::Ephemeral
);
assert!(resolve_family_binding(root.path()).unwrap().is_none());
}
#[test]
fn common_dir_binding_round_trips() {
let common = tempfile::tempdir().unwrap();
assert!(read_common_dir_binding(common.path()).unwrap().is_none());
write_common_dir_binding(common.path(), "fam", "abcdef0123456789").unwrap();
assert!(common.path().join("shore.link.json").is_file());
let binding = read_common_dir_binding(common.path())
.unwrap()
.expect("bound");
assert_eq!(binding.family_ref, "fam");
assert_eq!(binding.clone_ref, "abcdef0123456789");
}
#[test]
fn common_dir_binding_document_is_camel_case_store_link_schema() {
let common = tempfile::tempdir().unwrap();
write_common_dir_binding(common.path(), "fam", "abcdef0123456789").unwrap();
let raw = std::fs::read_to_string(common.path().join("shore.link.json")).unwrap();
assert!(
raw.contains("\"schema\": \"shore.store-link\""),
"got: {raw}"
);
assert!(raw.contains("\"familyRef\": \"fam\""), "got: {raw}");
assert!(
raw.contains("\"cloneRef\": \"abcdef0123456789\""),
"got: {raw}"
);
assert!(
raw.ends_with('\n'),
"trailing newline like the other writers"
);
}
#[test]
fn common_dir_binding_half_document_is_a_hard_error() {
let common = tempfile::tempdir().unwrap();
std::fs::write(
common.path().join("shore.link.json"),
r#"{"schema":"shore.store-link","version":1,"familyRef":"fam"}"#,
)
.unwrap();
let error =
read_common_dir_binding(common.path()).expect_err("a half binding is a hard error");
assert!(
error.to_string().contains("shore.link.json"),
"names the file"
);
}
#[test]
fn common_dir_binding_wrong_schema_version_is_a_hard_error() {
let common = tempfile::tempdir().unwrap();
std::fs::write(
common.path().join("shore.link.json"),
r#"{"schema":"shore.store-link","version":999,"familyRef":"f","cloneRef":"c"}"#,
)
.unwrap();
assert!(read_common_dir_binding(common.path()).is_err());
}
#[test]
fn remove_common_dir_binding_is_a_clean_no_op_when_absent() {
let common = tempfile::tempdir().unwrap();
remove_common_dir_binding(common.path()).unwrap();
write_common_dir_binding(common.path(), "fam", "abcdef0123456789").unwrap();
remove_common_dir_binding(common.path()).unwrap();
assert!(read_common_dir_binding(common.path()).unwrap().is_none());
}
#[test]
fn a_common_dir_binding_resolves() {
let repo = git_repo();
let common = crate::git::git_common_dir(repo.path()).unwrap();
write_common_dir_binding(&common, "fam", "abcdef0123456789").unwrap();
let binding = resolve_family_binding(repo.path()).unwrap().expect("bound");
assert_eq!(binding.family_ref, "fam");
assert_eq!(binding.clone_ref, "abcdef0123456789");
}
#[test]
fn a_legacy_per_worktree_binding_still_resolves_as_a_fallback() {
let repo = git_repo();
write(repo.path(), ".shore/store.local.json", LOCAL_BINDING_DOC);
let binding = resolve_family_binding(repo.path())
.unwrap()
.expect("bound via fallback");
assert_eq!(binding.family_ref, "acme-web");
}
#[test]
fn the_common_dir_binding_wins_over_a_legacy_per_worktree_binding() {
let repo = git_repo();
let common = crate::git::git_common_dir(repo.path()).unwrap();
write_common_dir_binding(&common, "new", "1111111111111111").unwrap();
write(repo.path(), ".shore/store.local.json", LOCAL_BINDING_DOC);
assert_eq!(
resolve_family_binding(repo.path())
.unwrap()
.unwrap()
.family_ref,
"new"
);
}
#[test]
fn set_family_binding_clears_a_local_ephemeral_pin_so_the_binding_resolves() {
let repo = git_repo();
write(repo.path(), ".shore/store.local.json", EPHEMERAL_DOC);
set_family_binding_for_repo(repo.path(), "acme-web", "0123abcd4567ef89").unwrap();
let binding = resolve_family_binding(repo.path())
.unwrap()
.expect("binding written");
assert_eq!(binding.family_ref, "acme-web");
assert_eq!(binding.clone_ref, "0123abcd4567ef89");
assert_eq!(resolve_store_mode(repo.path()).unwrap(), StoreMode::Shared);
}
#[test]
fn set_family_binding_leaves_the_committed_file_untouched() {
let repo = git_repo();
write(repo.path(), ".shore/store.json", SHARED_DOC);
set_family_binding_for_repo(repo.path(), "acme-web", "0123abcd4567ef89").unwrap();
let committed = std::fs::read_to_string(repo.path().join(".shore/store.json")).unwrap();
assert_eq!(committed, SHARED_DOC);
assert!(resolve_family_binding(repo.path()).unwrap().is_some());
}
#[test]
fn clear_family_binding_removes_the_binding_and_preserves_a_non_default_mode() {
let repo = git_repo();
write(
repo.path(),
".shore/store.local.json",
r#"{"schema":"shore.store-config","version":1,"mode":"ephemeral","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#,
);
clear_family_binding_for_repo(repo.path()).unwrap();
assert!(resolve_family_binding(repo.path()).unwrap().is_none());
assert_eq!(
resolve_store_mode(repo.path()).unwrap(),
StoreMode::Ephemeral
);
assert!(repo.path().join(".shore/store.local.json").is_file());
}
#[test]
fn set_family_binding_writes_the_common_dir_doc_not_the_local_file() {
let repo = git_repo();
set_family_binding_for_repo(repo.path(), "fam", "abcdef0123456789").unwrap();
let common = crate::git::git_common_dir(repo.path()).unwrap();
assert!(
common.join("shore.link.json").is_file(),
"binding in the common dir"
);
assert!(
!repo.path().join(".shore/store.local.json").exists(),
"no spurious per-worktree binding file"
);
assert_eq!(
resolve_family_binding(repo.path())
.unwrap()
.unwrap()
.family_ref,
"fam"
);
}
#[test]
fn clear_family_binding_removes_the_common_dir_doc() {
let repo = git_repo();
set_family_binding_for_repo(repo.path(), "fam", "abcdef0123456789").unwrap();
let common = crate::git::git_common_dir(repo.path()).unwrap();
assert!(common.join("shore.link.json").is_file());
clear_family_binding_for_repo(repo.path()).unwrap();
assert!(
!common.join("shore.link.json").exists(),
"common-dir binding removed"
);
assert!(resolve_family_binding(repo.path()).unwrap().is_none());
}
#[test]
fn clear_family_binding_also_clears_a_legacy_per_worktree_binding() {
let repo = git_repo();
write(repo.path(), ".shore/store.local.json", LOCAL_BINDING_DOC);
clear_family_binding_for_repo(repo.path()).unwrap();
assert!(resolve_family_binding(repo.path()).unwrap().is_none());
}
#[test]
fn set_family_binding_validates_the_slug_before_writing() {
let repo = git_repo();
let err = set_family_binding_for_repo(repo.path(), "Bad Slug", "0123abcd4567ef89")
.expect_err("an invalid slug is rejected before any write");
assert!(
err.to_string().contains("slug"),
"names the slug problem: {err}"
);
assert!(
!repo.path().join(".shore/store.local.json").exists(),
"no local file is written when the slug is invalid"
);
}
const SHARED_DOC: &str = r#"{"schema":"shore.store-config","version":1,"mode":"shared"}"#;
const EPHEMERAL_DOC: &str = r#"{"schema":"shore.store-config","version":1,"mode":"ephemeral"}"#;
}